@yfwu2020/dsh-selection-explain 0.10.3 → 0.10.5
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.
- package/README.md +13 -11
- package/cordis.patch.yml +1 -1
- package/lib/client.js +10694 -12424
- package/lib/client.js.map +7 -0
- package/lib/config-values.js +55 -0
- package/lib/config-values.js.map +1 -0
- package/lib/host/analyze.js +795 -0
- package/lib/host/analyze.js.map +1 -0
- package/lib/host/config.js +81 -0
- package/lib/host/config.js.map +1 -0
- package/lib/host/constants.js +25 -0
- package/lib/host/constants.js.map +1 -0
- package/lib/host/context.js +237 -0
- package/lib/host/context.js.map +1 -0
- package/lib/host/design-skill.js +42 -0
- package/lib/host/design-skill.js.map +1 -0
- package/lib/host/effort.js +239 -0
- package/lib/host/effort.js.map +1 -0
- package/lib/host/filters.js +132 -0
- package/lib/host/filters.js.map +1 -0
- package/lib/host/history-store.js +404 -0
- package/lib/host/history-store.js.map +1 -0
- package/lib/host/http.js +46 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/messages.js +14 -0
- package/lib/host/messages.js.map +1 -0
- package/lib/host/mic.js +246 -0
- package/lib/host/mic.js.map +1 -0
- package/lib/host/models.js +246 -0
- package/lib/host/models.js.map +1 -0
- package/lib/host/plugin.js +41 -0
- package/lib/host/plugin.js.map +1 -0
- package/lib/host/promote.js +219 -0
- package/lib/host/promote.js.map +1 -0
- package/lib/host/routes.js +237 -0
- package/lib/host/routes.js.map +1 -0
- package/lib/host/sessions.js +181 -0
- package/lib/host/sessions.js.map +1 -0
- package/lib/host/settings-store.js +140 -0
- package/lib/host/settings-store.js.map +1 -0
- package/lib/host/speech.js +320 -0
- package/lib/host/speech.js.map +1 -0
- package/lib/host/tools.js +179 -0
- package/lib/host/tools.js.map +1 -0
- package/lib/host/types.js +2 -0
- package/lib/host/types.js.map +1 -0
- package/lib/index.js +10 -3691
- package/lib/index.js.map +1 -1
- package/lib/prompts.js +1 -1
- package/lib/settings.js +17 -27
- package/lib/settings.js.map +1 -1
- package/lib/types/config-values.d.ts +175 -0
- package/lib/types/host/analyze.d.ts +10 -0
- package/lib/types/host/config.d.ts +147 -0
- package/lib/types/host/constants.d.ts +24 -0
- package/lib/types/host/context.d.ts +41 -0
- package/lib/types/host/design-skill.d.ts +1 -0
- package/lib/types/host/effort.d.ts +61 -0
- package/lib/types/host/filters.d.ts +20 -0
- package/lib/types/host/history-store.d.ts +13 -0
- package/lib/types/host/http.d.ts +8 -0
- package/lib/types/host/messages.d.ts +13 -0
- package/lib/types/host/mic.d.ts +17 -0
- package/lib/types/host/models.d.ts +57 -0
- package/lib/types/host/plugin.d.ts +3 -0
- package/lib/types/host/promote.d.ts +9 -0
- package/lib/types/host/routes.d.ts +14 -0
- package/lib/types/host/sessions.d.ts +14 -0
- package/lib/types/host/settings-store.d.ts +8 -0
- package/lib/types/host/speech.d.ts +23 -0
- package/lib/types/host/tools.d.ts +39 -0
- package/lib/types/host/types.d.ts +89 -0
- package/lib/types/index.d.ts +12 -441
- package/lib/types/prompts.d.ts +1 -1
- package/lib/types/settings.d.ts +1 -1
- package/package.json +31 -21
- package/assets/code.png +0 -0
- package/assets/detail.png +0 -0
- package/assets/quotes.png +0 -0
- package/assets/stage1.png +0 -0
- package/assets/stage2.png +0 -0
- package/assets/translation.png +0 -0
- package/assets/voice-card-light.png +0 -0
- package/assets/voice-inserted.png +0 -0
- package/assets/voice-settings.png +0 -0
- package/assets/voice.png +0 -0
- package/assets/web.png +0 -0
- package/scripts/build-demo-pages.mjs +0 -350
- package/scripts/build-vcard-lab.mjs +0 -834
- package/scripts/build.sh +0 -147
- package/scripts/crop-png.mjs +0 -142
- package/scripts/dump-prompt.mjs +0 -143
- package/scripts/fixtures/fake-mic-probe.sh +0 -9
- package/scripts/gen-star-path.mjs +0 -71
- package/scripts/measure-cost.mjs +0 -251
- package/scripts/smoke-bridge.mjs +0 -365
- package/scripts/smoke-panel.mjs +0 -1903
- package/scripts/smoke-voice.mjs +0 -774
- package/scripts/test-bridge.mjs +0 -412
- package/scripts/test-client.mjs +0 -6175
- package/scripts/test-effort-map.mjs +0 -129
- package/scripts/test-effort-retry.mjs +0 -109
- package/scripts/test-escape-focus.mjs +0 -101
- package/scripts/test-filters.mjs +0 -146
- package/scripts/test-format.mjs +0 -93
- package/scripts/test-guard.mjs +0 -48
- package/scripts/test-mic-idle.mjs +0 -158
- package/scripts/test-model-follow.mjs +0 -193
- package/scripts/test-prompt.mjs +0 -193
- package/scripts/test-route-cache.mjs +0 -138
- package/scripts/test-settings-behavior.mjs +0 -188
- package/scripts/test-settings-host.mjs +0 -48
- package/scripts/test-settings.mjs +0 -329
- package/scripts/test-speech.mjs +0 -220
- package/scripts/test-transcript.mjs +0 -151
- package/src/client/index.js +0 -12580
- package/src/index.ts +0 -4179
- package/src/prompts.ts +0 -144
- package/src/settings.ts +0 -313
package/scripts/measure-cost.mjs
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 「在小窗里问简单问题,比在主会话里问省吗?」—— 一次算完。
|
|
3
|
-
*
|
|
4
|
-
* 两边分别怎么量:
|
|
5
|
-
* · **小窗这一侧**:真发一次请求,拿 SSE `done` 事件里的 `usage`(真实计费口径);
|
|
6
|
-
* 跑 chat 追问两遍(冷 / 热缓存)与「不带会话背景」对照,隔离固定开销与背景开销。
|
|
7
|
-
* · **主会话这一侧**:不猜 —— 读 ~/.dsh/storages/dsh-spend-scan-cache.json,
|
|
8
|
-
* 那是每个会话每一步的真实 inputTokens / cacheReadTokens / outputTokens:
|
|
9
|
-
* 首步 input = 固定前缀(系统提示词 + 全部工具 schema + 运行时快照),
|
|
10
|
-
* 按轮次聚合就能得到「一次一问一答」的真实开销分布。
|
|
11
|
-
*
|
|
12
|
-
* 折算口径("等效未缓存输入 token",所有 token 归一到 miss 单价):
|
|
13
|
-
* cost = miss + cacheRead × 0.1 + output × 1.5
|
|
14
|
-
* 0.1 = 主流缓存命中价(DeepSeek 系 miss:hit = 10:1);1.5 = 输出单价 / 输入 miss 单价。
|
|
15
|
-
* 价格表换了就改这两个常数;结论对这俩数不敏感的方向会在输出里标注。
|
|
16
|
-
*
|
|
17
|
-
* 用法:
|
|
18
|
-
* node scripts/measure-cost.mjs # 用小窗历史里最近一次会话当背景
|
|
19
|
-
* node scripts/measure-cost.mjs <sessionId> # 指定主会话 id(拿它的对话当小窗背景)
|
|
20
|
-
* SEL_ORIGIN=http://127.0.0.1:3080 node scripts/measure-cost.mjs
|
|
21
|
-
*/
|
|
22
|
-
import { readFileSync } from 'node:fs'
|
|
23
|
-
import { homedir } from 'node:os'
|
|
24
|
-
import { join } from 'node:path'
|
|
25
|
-
|
|
26
|
-
const ORIGIN = process.env.SEL_ORIGIN || 'http://127.0.0.1:3080'
|
|
27
|
-
const SPEND_CACHE = join(homedir(), '.dsh', 'storages', 'dsh-spend-scan-cache.json')
|
|
28
|
-
const CACHE_READ_RATIO = 0.1
|
|
29
|
-
const OUTPUT_RATIO = 1.5
|
|
30
|
-
|
|
31
|
-
const sessionId = process.argv[2] || ''
|
|
32
|
-
/** 折算:把 (miss, cacheRead, output) 归一到「等效未缓存输入 token」。 */
|
|
33
|
-
const cost = (miss, cacheRead, out) => miss + cacheRead * CACHE_READ_RATIO + out * OUTPUT_RATIO
|
|
34
|
-
const fmt = (n) => Math.round(n).toLocaleString('en-US')
|
|
35
|
-
/** 分位数(p 取 0~1);中位数就是 p=0.5。 */
|
|
36
|
-
const pct = (list, p) => {
|
|
37
|
-
if (list.length === 0) return 0
|
|
38
|
-
const sorted = [...list].sort((a, b) => a - b)
|
|
39
|
-
const at = Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * p)))
|
|
40
|
-
return sorted[at]
|
|
41
|
-
}
|
|
42
|
-
const med = (list) => pct(list, 0.5)
|
|
43
|
-
|
|
44
|
-
/* ───────────────────────── 主会话:真实用量统计 ───────────────────────── */
|
|
45
|
-
|
|
46
|
-
function mainSessionStats() {
|
|
47
|
-
let cache
|
|
48
|
-
try {
|
|
49
|
-
cache = JSON.parse(readFileSync(SPEND_CACHE, 'utf8'))
|
|
50
|
-
} catch (error) {
|
|
51
|
-
return { error: `读不到用量缓存(${SPEND_CACHE}):${String(error?.message ?? error)}` }
|
|
52
|
-
}
|
|
53
|
-
const prefixes = []
|
|
54
|
-
const turns = new Map()
|
|
55
|
-
for (const [key, value] of Object.entries(cache)) {
|
|
56
|
-
const samples = value?.samples ?? []
|
|
57
|
-
if (samples.length === 0) continue
|
|
58
|
-
const first = samples.reduce((a, b) => ((a?.turn ?? 0) <= (b?.turn ?? 0) ? a : b))
|
|
59
|
-
if (first?.inputTokens) prefixes.push(first.inputTokens)
|
|
60
|
-
for (const sample of samples) {
|
|
61
|
-
const id = `${key}#${sample.turn}`
|
|
62
|
-
const turn = turns.get(id) ?? { steps: 0, miss: 0, cacheRead: 0, out: 0 }
|
|
63
|
-
turn.steps += 1
|
|
64
|
-
turn.miss += sample.inputTokens ?? 0
|
|
65
|
-
turn.cacheRead += sample.cacheReadTokens ?? 0
|
|
66
|
-
turn.out += sample.outputTokens ?? 0
|
|
67
|
-
turns.set(id, turn)
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const all = [...turns.values()]
|
|
71
|
-
const bucket = (max) => all.filter((t) => t.steps <= max)
|
|
72
|
-
const summarize = (list) => ({
|
|
73
|
-
n: list.length,
|
|
74
|
-
steps: med(list.map((t) => t.steps)),
|
|
75
|
-
miss: med(list.map((t) => t.miss)),
|
|
76
|
-
cacheRead: med(list.map((t) => t.cacheRead)),
|
|
77
|
-
out: med(list.map((t) => t.out)),
|
|
78
|
-
costP25: pct(list.map((t) => cost(t.miss, t.cacheRead, t.out)), 0.25),
|
|
79
|
-
cost: med(list.map((t) => cost(t.miss, t.cacheRead, t.out))),
|
|
80
|
-
})
|
|
81
|
-
return {
|
|
82
|
-
sessions: prefixes.length,
|
|
83
|
-
turns: all.length,
|
|
84
|
-
prefix: med(prefixes),
|
|
85
|
-
prefixP25: med([...prefixes].sort((a, b) => a - b).slice(0, Math.max(1, prefixes.length >> 2))),
|
|
86
|
-
one: summarize(bucket(1)),
|
|
87
|
-
two: summarize(bucket(2)),
|
|
88
|
-
six: summarize(bucket(6)),
|
|
89
|
-
all: summarize(all),
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/* ───────────────────────── 小窗:实测一次请求 ───────────────────────── */
|
|
94
|
-
|
|
95
|
-
const CONTEXT =
|
|
96
|
-
'PM:这周能发布吗?\nDev:【the migration ran long】,得顺延到周三。\nPM:那就周三,先冻结功能。\n' +
|
|
97
|
-
'Dev:迁移脚本在预发环境跑了 47 分钟,主要是 users 表的索引重建。\n'.repeat(6)
|
|
98
|
-
|
|
99
|
-
const HISTORY = [
|
|
100
|
-
{ role: 'user', text: 'the migration ran long' },
|
|
101
|
-
{
|
|
102
|
-
role: 'assistant',
|
|
103
|
-
text: '## 翻译\n- **migration** /maɪˈɡreɪʃn/\n - **n.** 迁移;移居\n- **ran long**:跑得久了。\n\n> 在本句中:迁移脚本耗时超出预期。',
|
|
104
|
-
},
|
|
105
|
-
]
|
|
106
|
-
|
|
107
|
-
/** 发一次小窗请求,只取 start 元信息与 done 的 usage。 */
|
|
108
|
-
async function ask(payload, withSession) {
|
|
109
|
-
const response = await fetch(`${ORIGIN}/selection-explain/api/analyze`, {
|
|
110
|
-
method: 'POST',
|
|
111
|
-
headers: { 'content-type': 'application/json' },
|
|
112
|
-
body: JSON.stringify({
|
|
113
|
-
text: 'the migration ran long',
|
|
114
|
-
context: CONTEXT,
|
|
115
|
-
label: '对话消息',
|
|
116
|
-
debug: true,
|
|
117
|
-
...(withSession && sessionId ? { sessionId } : {}),
|
|
118
|
-
...payload,
|
|
119
|
-
}),
|
|
120
|
-
})
|
|
121
|
-
if (!response.ok) return { error: `HTTP ${response.status} ${(await response.text().catch(() => '')).slice(0, 120)}` }
|
|
122
|
-
const reader = response.body.getReader()
|
|
123
|
-
const decoder = new TextDecoder()
|
|
124
|
-
let buffer = ''
|
|
125
|
-
let start = null
|
|
126
|
-
let done = null
|
|
127
|
-
const startedAt = Date.now()
|
|
128
|
-
let firstDelta = null
|
|
129
|
-
for (;;) {
|
|
130
|
-
const { done: eof, value } = await reader.read()
|
|
131
|
-
if (eof) break
|
|
132
|
-
buffer += decoder.decode(value, { stream: true })
|
|
133
|
-
let index
|
|
134
|
-
while ((index = buffer.indexOf('\n\n')) >= 0) {
|
|
135
|
-
const block = buffer.slice(0, index)
|
|
136
|
-
buffer = buffer.slice(index + 2)
|
|
137
|
-
const line = block.trim()
|
|
138
|
-
if (!line.startsWith('data:')) continue
|
|
139
|
-
let message
|
|
140
|
-
try {
|
|
141
|
-
message = JSON.parse(line.slice(5).trim())
|
|
142
|
-
} catch {
|
|
143
|
-
continue
|
|
144
|
-
}
|
|
145
|
-
if (message.type === 'start') start = message
|
|
146
|
-
else if (message.type === 'delta' && firstDelta === null) firstDelta = Date.now() - startedAt
|
|
147
|
-
else if (message.type === 'done') done = message
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return { start, done, ms: Date.now() - startedAt, firstDelta }
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const QUESTION = { question: '这句里 run long 是什么用法?', history: HISTORY }
|
|
154
|
-
|
|
155
|
-
async function smallWindowStats() {
|
|
156
|
-
const rows = []
|
|
157
|
-
const cold = await ask(QUESTION, true)
|
|
158
|
-
if (cold.error) return { error: cold.error }
|
|
159
|
-
const warm = await ask(QUESTION, true)
|
|
160
|
-
const bare = await ask(QUESTION, false)
|
|
161
|
-
for (const [label, run] of [
|
|
162
|
-
['第 1 跑', cold],
|
|
163
|
-
['再跑一次(前缀命中缓存)', warm],
|
|
164
|
-
['不带会话背景', bare],
|
|
165
|
-
]) {
|
|
166
|
-
const usage = run.done?.usage ?? {}
|
|
167
|
-
const miss = usage.inputTokens ?? 0
|
|
168
|
-
const cacheRead = usage.cacheReadTokens ?? 0
|
|
169
|
-
const out = usage.outputTokens ?? 0
|
|
170
|
-
rows.push({
|
|
171
|
-
label,
|
|
172
|
-
prompt: miss + cacheRead,
|
|
173
|
-
miss,
|
|
174
|
-
cacheRead,
|
|
175
|
-
out,
|
|
176
|
-
cost: cost(miss, cacheRead, out),
|
|
177
|
-
rounds: run.done?.rounds?.length ?? 1,
|
|
178
|
-
toolCalls: run.done?.toolCalls ?? 0,
|
|
179
|
-
system: run.start?.debug?.sizes?.system ?? 0,
|
|
180
|
-
user: run.start?.debug?.sizes?.user ?? 0,
|
|
181
|
-
background: run.start?.context?.session?.chars ?? 0,
|
|
182
|
-
backgroundMessages: run.start?.backgroundMessages ?? 0,
|
|
183
|
-
tools: run.start?.tools ?? [],
|
|
184
|
-
ms: run.ms,
|
|
185
|
-
firstDelta: run.firstDelta,
|
|
186
|
-
})
|
|
187
|
-
}
|
|
188
|
-
return { rows, model: cold.start?.model, effort: cold.start?.effort }
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/* ───────────────────────────── 输出对照 ───────────────────────────── */
|
|
192
|
-
|
|
193
|
-
const main = mainSessionStats()
|
|
194
|
-
const small = await smallWindowStats()
|
|
195
|
-
|
|
196
|
-
console.log('='.repeat(78))
|
|
197
|
-
console.log('主会话(真实用量:%d 个会话 / %d 轮)', main.sessions ?? 0, main.turns ?? 0)
|
|
198
|
-
console.log('='.repeat(78))
|
|
199
|
-
if (main.error) console.log(' ' + main.error)
|
|
200
|
-
else {
|
|
201
|
-
console.log(`固定前缀(首步 input,= 系统提示词 + 全部工具 schema + 运行时快照):中位 ${fmt(main.prefix)} tokens`)
|
|
202
|
-
const line = (name, s) =>
|
|
203
|
-
console.log(
|
|
204
|
-
` ${name.padEnd(16)} n=${String(s.n).padStart(3)} 步数中位 ${String(s.steps).padStart(4)} ` +
|
|
205
|
-
`miss ${fmt(s.miss).padStart(8)} cacheRead ${fmt(s.cacheRead).padStart(9)} output ${fmt(s.out).padStart(6)}` +
|
|
206
|
-
` → 等效 token p25 ${fmt(s.costP25).padStart(9)} / 中位 ${fmt(s.cost).padStart(9)}`,
|
|
207
|
-
)
|
|
208
|
-
line('1 步轮次', main.one)
|
|
209
|
-
line('≤2 步轮次', main.two)
|
|
210
|
-
line('≤6 步轮次', main.six)
|
|
211
|
-
line('全部轮次', main.all)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
console.log()
|
|
215
|
-
console.log('='.repeat(78))
|
|
216
|
-
console.log('小窗(实测:同一句简单提问,%s / %s)', small.model ?? '?', small.effort ?? '?')
|
|
217
|
-
console.log('='.repeat(78))
|
|
218
|
-
if (small.error) console.log(' ' + small.error)
|
|
219
|
-
else {
|
|
220
|
-
for (const r of small.rows) {
|
|
221
|
-
console.log(
|
|
222
|
-
` ${r.label}:prompt ${fmt(r.prompt)} tokens(miss ${fmt(r.miss)} + cacheRead ${fmt(r.cacheRead)})` +
|
|
223
|
-
` + output ${fmt(r.out)} → ≈ ${fmt(r.cost)} 等效 token`,
|
|
224
|
-
)
|
|
225
|
-
console.log(
|
|
226
|
-
` system ${r.system} 字 / user ${r.user} 字 / 背景 ${r.background} 字(${r.backgroundMessages} 条真实对话)` +
|
|
227
|
-
` / ${r.rounds} 轮 / ${r.toolCalls} 次工具调用 / 首字 ${r.firstDelta}ms / 全程 ${(r.ms / 1000).toFixed(1)}s`,
|
|
228
|
-
)
|
|
229
|
-
}
|
|
230
|
-
const best = Math.min(...small.rows.map((r) => r.cost))
|
|
231
|
-
const worst = Math.max(...small.rows.map((r) => r.cost))
|
|
232
|
-
if (!main.error) {
|
|
233
|
-
console.log()
|
|
234
|
-
console.log('= 对照(等效未缓存输入 token,越小越省;主会话一侧给 p25~中位)')
|
|
235
|
-
const rows = [
|
|
236
|
-
['主会话 1 步(最省的一问)', main.one.costP25, main.one.cost],
|
|
237
|
-
['主会话 ≤2 步', main.two.costP25, main.two.cost],
|
|
238
|
-
['主会话 ≤6 步', main.six.costP25, main.six.cost],
|
|
239
|
-
['主会话中位轮次', main.all.costP25, main.all.cost],
|
|
240
|
-
]
|
|
241
|
-
for (const [name, low, high] of rows) {
|
|
242
|
-
console.log(
|
|
243
|
-
` ${name.padEnd(26)} ${fmt(low).padStart(8)} ~ ${fmt(high).padStart(8)}` +
|
|
244
|
-
` 小窗省 ${(low / worst).toFixed(1)} ~ ${(high / best).toFixed(1)}×`,
|
|
245
|
-
)
|
|
246
|
-
}
|
|
247
|
-
console.log(` ${'小窗(实测上下界)'.padEnd(26)} ${fmt(best).padStart(8)} ~ ${fmt(worst).padStart(8)}`)
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
console.log()
|
|
251
|
-
console.log(`折算口径:miss + cacheRead×${CACHE_READ_RATIO} + output×${OUTPUT_RATIO}(改脚本顶部两个常数即可换价格表)`)
|
package/scripts/smoke-bridge.mjs
DELETED
|
@@ -1,365 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 划词桥的**真浏览器**冒烟测试(本机 Chrome headless)。
|
|
3
|
-
*
|
|
4
|
-
* 为什么需要它:沙箱 / CSP / 跨不透明源 postMessage / document.write 冲掉监听
|
|
5
|
-
* ——这些行为只有真浏览器引擎说了算。`scripts/test-bridge.mjs` 只测纯逻辑,
|
|
6
|
-
* `scripts/test-client.mjs` 只测父侧接线(DOM 桩)。
|
|
7
|
-
*
|
|
8
|
-
* 做法:起一个临时 http 服务(blob: 的导航在 file:// 下会被拒,必须走 http 源),
|
|
9
|
-
* 页面上放两个 iframe 模拟宿主 ui-sidebar-documentpreview 的两种 HTML 预览:
|
|
10
|
-
* A. 基础预览:srcdoc + sandbox="allow-scripts" + 清洗版 CSP(script-src 'none' → 放宽)
|
|
11
|
-
* B. 交互式预览:blob: 外层 bootstrap(document.open/write 整份文档)+ sandbox="allow-scripts"
|
|
12
|
-
* 两份文档都用 lib/client.js 里**同一份** bridgeIntoHtml 注入(不是抄一份代码),
|
|
13
|
-
* 再由文档内的脚本**同步**造一段选区 → 断言父页面收到了 __dshSel 消息。
|
|
14
|
-
*
|
|
15
|
-
* 同步是必须的:headless 的 --virtual-time-budget 不推进子框架里的定时器
|
|
16
|
-
* (实测:主文档的定时器会跑、iframe 里的不跑),而桥的即时路径正是
|
|
17
|
-
* `selectionchange`(document 监听、同步触发),所以不依赖轮询也能验证。
|
|
18
|
-
*
|
|
19
|
-
* 用法:node scripts/smoke-bridge.mjs (找不到 Chrome 就 SKIP,退出码 0)
|
|
20
|
-
* CHROME=/path/to/chrome node scripts/smoke-bridge.mjs
|
|
21
|
-
* node scripts/smoke-bridge.mjs --dump > /tmp/x.html (只产出页面,不跑浏览器)
|
|
22
|
-
*/
|
|
23
|
-
import { execFile } from 'node:child_process'
|
|
24
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
25
|
-
import { createServer } from 'node:http'
|
|
26
|
-
import { dirname, resolve } from 'node:path'
|
|
27
|
-
import { fileURLToPath } from 'node:url'
|
|
28
|
-
import { promisify } from 'node:util'
|
|
29
|
-
|
|
30
|
-
const execFileAsync = promisify(execFile)
|
|
31
|
-
|
|
32
|
-
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
33
|
-
const BUNDLE = resolve(HERE, '..', 'lib', 'client.js')
|
|
34
|
-
const source = readFileSync(BUNDLE, 'utf8')
|
|
35
|
-
|
|
36
|
-
/** 从 bundle 里抠一个具名函数(与 test-bridge.mjs 同一套配平逻辑)。 */
|
|
37
|
-
function sliceFunction(text, name) {
|
|
38
|
-
const start = text.indexOf('function ' + name + '(')
|
|
39
|
-
if (start < 0) throw new Error('bundle 里找不到函数:' + name)
|
|
40
|
-
let depth = 0
|
|
41
|
-
for (let i = text.indexOf('{', start); i < text.length; i += 1) {
|
|
42
|
-
const ch = text[i]
|
|
43
|
-
if (ch === '{') depth += 1
|
|
44
|
-
else if (ch === '}') {
|
|
45
|
-
depth -= 1
|
|
46
|
-
if (depth === 0) return text.slice(start, i + 1)
|
|
47
|
-
} else if (ch === "'" || ch === '"' || ch === '`') {
|
|
48
|
-
for (i += 1; i < text.length; i += 1) {
|
|
49
|
-
if (text[i] === '\\') i += 1
|
|
50
|
-
else if (text[i] === ch) break
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
throw new Error('函数没配平:' + name)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const injector = [
|
|
58
|
-
sliceFunction(source, 'bridgeBody'),
|
|
59
|
-
sliceFunction(source, 'bridgeIntoHtml'),
|
|
60
|
-
sliceFunction(source, 'insertIntoHtml'),
|
|
61
|
-
'return bridgeIntoHtml',
|
|
62
|
-
].join('\n')
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* 造一段选区并**同步**敲一下 selectionchange。
|
|
66
|
-
*
|
|
67
|
-
* 真实的 selectionchange 是**异步任务**(规范里 queue a task),而 headless 的虚拟时钟在
|
|
68
|
-
* "帧加载 vs 预算耗尽"上有竞态,异步任务可能来不及跑(实测:同一页面 A 报得到、C 报不到)。
|
|
69
|
-
* 桥对两者的处理是同一条代码路径,所以这里补一次同步派发,让冒烟测试稳定可判。
|
|
70
|
-
*/
|
|
71
|
-
const selectScript = (start, end, tag) =>
|
|
72
|
-
'<scr' +
|
|
73
|
-
'ipt>try{parent.postMessage("alive-' +
|
|
74
|
-
tag +
|
|
75
|
-
'","*");var p=document.querySelector("p");var r=document.createRange();r.setStart(p.firstChild,' +
|
|
76
|
-
start +
|
|
77
|
-
');r.setEnd(p.firstChild,' +
|
|
78
|
-
end +
|
|
79
|
-
');var s=getSelection();s.removeAllRanges();s.addRange(r);' +
|
|
80
|
-
'document.dispatchEvent(new Event("selectionchange"))}catch(e){parent.postMessage("err-' +
|
|
81
|
-
tag +
|
|
82
|
-
':"+e,"*")}</scr' +
|
|
83
|
-
'ipt>'
|
|
84
|
-
|
|
85
|
-
/** 基础预览那份文档:宿主 DOMPurify 清洗后的形态(无脚本、带 script-src 'none' 的 CSP)。 */
|
|
86
|
-
const basicDoc = [
|
|
87
|
-
'<!doctype html><html><head>',
|
|
88
|
-
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; script-src \'none\'; style-src \'unsafe-inline\'; img-src data:; connect-src \'none\'; frame-src \'none\'; form-action \'none\'; base-uri \'none\'">',
|
|
89
|
-
'<style>body{font:14px/1.7 -apple-system,sans-serif;padding:12px}</style>',
|
|
90
|
-
'</head><body>',
|
|
91
|
-
'<p>PM: 这周能发布吗? Dev: the migration ran long, so we ship Wednesday.</p>',
|
|
92
|
-
// 这段脚本在原始文档里被 CSP 挡着(script-src 'none')——注入放宽后它才会跑
|
|
93
|
-
selectScript(17, 39, 'A'),
|
|
94
|
-
'</body></html>',
|
|
95
|
-
].join('')
|
|
96
|
-
|
|
97
|
-
/** 交互式预览的页面正文(会被 bootstrap 用 document.write 写进来)。 */
|
|
98
|
-
const interactiveDoc = [
|
|
99
|
-
'<!doctype html><html><head><style>body{font:14px/1.7 sans-serif;padding:12px}</style></head><body>',
|
|
100
|
-
'<p>Release note: the deadline slipped to Wednesday because the migration ran long.</p>',
|
|
101
|
-
selectScript(14, 29, 'B'),
|
|
102
|
-
'</body></html>',
|
|
103
|
-
].join('')
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* 「图解」那种帧(别的插件在侧边栏渲染的生成网页):blob: + allow-scripts、**没有宿主标记**。
|
|
107
|
-
* 顺便验浮现时机:按住鼠标拖拽期间不许上报,松手之后才报。
|
|
108
|
-
*
|
|
109
|
-
* 编码也照线上来:**页面自己不写 charset**,编码只由 blob 的 `text/html;charset=utf-8` 提供
|
|
110
|
-
* (「图解」就是这么建 blob 的)——插件重发那一份时必须把 content-type 搬过去,
|
|
111
|
-
* 否则整页中文会变成乱码(实测 `鍥捐В椤?`)。
|
|
112
|
-
*/
|
|
113
|
-
const visualDoc = [
|
|
114
|
-
'<!doctype html><html><head><style>body{font:14px/1.7 sans-serif;padding:12px}</style></head><body>',
|
|
115
|
-
'<p>图解页:迁移跑得久,发布顺延到周三。</p>',
|
|
116
|
-
'<scr' +
|
|
117
|
-
'ipt>try{' +
|
|
118
|
-
'parent.postMessage("alive-C","*");' +
|
|
119
|
-
'window.dispatchEvent(new MouseEvent("mousedown"));' +
|
|
120
|
-
'var p=document.querySelector("p");var r=document.createRange();r.setStart(p.firstChild,5);r.setEnd(p.firstChild,12);' +
|
|
121
|
-
'var s=getSelection();s.removeAllRanges();s.addRange(r);' +
|
|
122
|
-
'parent.postMessage("C-drag-mid","*");' +
|
|
123
|
-
'window.dispatchEvent(new MouseEvent("mouseup"));' +
|
|
124
|
-
// 松手后再动一下选区:这一步必然触发 selectionchange,用来验证闸门已经放开
|
|
125
|
-
// (帧内定时器在 headless 虚拟时间下不推进,所以不能靠 mouseup 里那个 setTimeout)
|
|
126
|
-
'r.setEnd(p.firstChild,14);s.removeAllRanges();s.addRange(r);' +
|
|
127
|
-
'document.dispatchEvent(new Event("selectionchange"));' +
|
|
128
|
-
'parent.postMessage("C-drag-end","*");' +
|
|
129
|
-
// ── 消失时机:在网页里点一下(取消选区)→ 桥要先发 press、再发 clear ──
|
|
130
|
-
'window.dispatchEvent(new MouseEvent("mousedown"));' +
|
|
131
|
-
's.removeAllRanges();' +
|
|
132
|
-
'parent.postMessage("C-click-mid","*");' +
|
|
133
|
-
'window.dispatchEvent(new MouseEvent("mouseup"));' +
|
|
134
|
-
// 同理:headless 里帧内定时器不跑,手动补一次 selectionchange 让 clear 同步发出来
|
|
135
|
-
'document.dispatchEvent(new Event("selectionchange"));' +
|
|
136
|
-
'parent.postMessage("C-click-end","*")' +
|
|
137
|
-
'}catch(e){parent.postMessage("err-C:"+e,"*")}</scr' +
|
|
138
|
-
'ipt>',
|
|
139
|
-
'</body></html>',
|
|
140
|
-
].join('')
|
|
141
|
-
|
|
142
|
-
/** JSON 内嵌进 <script> 时必须把 </ 转义,否则文档里的 </script> 会把父页面脚本截断。 */
|
|
143
|
-
const jsonInScript = (value) => JSON.stringify(value).replace(/<\//g, '<\\/')
|
|
144
|
-
|
|
145
|
-
const page = `<!doctype html><html><head><meta charset="utf-8"><title>bridge-smoke</title></head>
|
|
146
|
-
<body>
|
|
147
|
-
<h3>bridge smoke</h3>
|
|
148
|
-
<div id="frames"></div>
|
|
149
|
-
<pre id="out">pending</pre>
|
|
150
|
-
<script>
|
|
151
|
-
// BRIDGE_MARK 是 bundle 里的模块级常量:抠出来的函数要用它,这里补一个同名变量
|
|
152
|
-
var BRIDGE_MARK = 'data-dsh-sel-bridge';
|
|
153
|
-
var bridgeIntoHtml = (function(){${injector}})();
|
|
154
|
-
var results = [];
|
|
155
|
-
var log = [];
|
|
156
|
-
var seq = [];
|
|
157
|
-
function flush() { document.getElementById('out').textContent = JSON.stringify({ msgs: results, diag: log, seq: seq }, null, 1) }
|
|
158
|
-
function diag(line) { log.push(line); flush() }
|
|
159
|
-
window.onerror = function (message, src, line, col) { diag('ERR ' + message + ' @' + line + ':' + col) }
|
|
160
|
-
function nameOf(source) {
|
|
161
|
-
if (source === frameA.contentWindow) return 'A'
|
|
162
|
-
if (source === frameB.contentWindow) return 'B'
|
|
163
|
-
if (source === frameC.contentWindow) return 'C'
|
|
164
|
-
return '?'
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
window.addEventListener('message', function (event) {
|
|
168
|
-
var data = event.data
|
|
169
|
-
var from = nameOf(event.source)
|
|
170
|
-
if (!data || data.__dshSel !== 1) { seq.push('raw:' + from + ':' + String(data).slice(0, 40)); diag('raw:' + from + ':' + String(data).slice(0, 60)); return }
|
|
171
|
-
seq.push(from + ':' + data.kind)
|
|
172
|
-
results.push({ kind: data.kind, from: from, text: data.sel && data.sel.text, context: data.sel && data.sel.context, rect: data.sel && data.sel.rect })
|
|
173
|
-
flush()
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
// ── A:基础预览。宿主先渲染 srcdoc(sandbox=""),插件随后重写 srcdoc + 补 allow-scripts。
|
|
177
|
-
// 实测(Chrome 153):沙箱与 srcdoc **必须在同一个任务里、且先改沙箱**,
|
|
178
|
-
// 否则帧不会带着 allow-scripts 重新加载(见 docs/DEVELOPMENT.md 的"两个坑")。
|
|
179
|
-
var basicHtml = ${jsonInScript(basicDoc)};
|
|
180
|
-
var frameA = document.createElement('iframe')
|
|
181
|
-
frameA.setAttribute('data-html-preview', 'true')
|
|
182
|
-
frameA.setAttribute('sandbox', '')
|
|
183
|
-
frameA.setAttribute('srcdoc', basicHtml)
|
|
184
|
-
frameA.style.cssText = 'width:420px;height:140px;border:1px solid #999'
|
|
185
|
-
document.getElementById('frames').appendChild(frameA)
|
|
186
|
-
|
|
187
|
-
var nextA = bridgeIntoHtml(basicHtml)
|
|
188
|
-
frameA.setAttribute('sandbox', 'allow-scripts')
|
|
189
|
-
frameA.setAttribute('srcdoc', nextA)
|
|
190
|
-
diag('A injected marker=' + (nextA.indexOf('data-dsh-sel-bridge') > 0) + ' cspRelaxed=' + (nextA.indexOf("script-src 'unsafe-inline'") > 0))
|
|
191
|
-
|
|
192
|
-
// ── B:交互式预览。外层是 blob bootstrap(document.write),同样注入后换成我们那份 blob。
|
|
193
|
-
// bootstrap 里写进去的文档必须把闭合的 script 标签转义(宿主的真 bootstrap 用 base64
|
|
194
|
-
// 达到同样效果),否则内层文档里的闭合标签会把 bootstrap 自己的 script 元素截断。
|
|
195
|
-
var innerHtml = ${jsonInScript(interactiveDoc)};
|
|
196
|
-
var bootstrap = '<!doctype html><meta charset="utf-8"><scr' + 'ipt>(function(){document.open();document.write(' + JSON.stringify(innerHtml).replace(/<\\//g, '<\\\\/') + ');document.close()})()</scr' + 'ipt>';
|
|
197
|
-
var frameB = document.createElement('iframe')
|
|
198
|
-
frameB.setAttribute('data-html-preview', 'true')
|
|
199
|
-
frameB.setAttribute('sandbox', 'allow-scripts')
|
|
200
|
-
frameB.style.cssText = 'width:420px;height:140px;border:1px solid #999'
|
|
201
|
-
document.getElementById('frames').appendChild(frameB)
|
|
202
|
-
|
|
203
|
-
var bootstrapBlob = URL.createObjectURL(new Blob([bootstrap], { type: 'text/html' }))
|
|
204
|
-
fetch(bootstrapBlob).then(function (r) {
|
|
205
|
-
var type = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
|
|
206
|
-
return r.text().then(function (text) { return { text: text, type: type } })
|
|
207
|
-
}).then(function (loaded) {
|
|
208
|
-
var injected = bridgeIntoHtml(loaded.text)
|
|
209
|
-
var owned = URL.createObjectURL(new Blob([injected], { type: loaded.type }))
|
|
210
|
-
frameB.setAttribute('src', owned)
|
|
211
|
-
diag('B injected marker=' + (String(injected).indexOf('data-dsh-sel-bridge') > 0) + ' srcOwned=' + (frameB.getAttribute('src') === owned) + ' type=' + loaded.type)
|
|
212
|
-
}).catch(function (error) { diag('B inject threw ' + error) })
|
|
213
|
-
|
|
214
|
-
// ── C:「图解」那种帧(别的插件的生成网页):blob: + allow-scripts、没有宿主标记,
|
|
215
|
-
// 插件照样要桥它;同时验拖拽期间不上报(浮现时机)。
|
|
216
|
-
var visualHtml = ${jsonInScript(visualDoc)};
|
|
217
|
-
var frameC = document.createElement('iframe')
|
|
218
|
-
frameC.setAttribute('title', '图解')
|
|
219
|
-
frameC.setAttribute('sandbox', 'allow-scripts allow-forms allow-modals allow-popups')
|
|
220
|
-
frameC.style.cssText = 'width:420px;height:140px;border:1px solid #999'
|
|
221
|
-
document.getElementById('frames').appendChild(frameC)
|
|
222
|
-
|
|
223
|
-
// 与插件 bridgeFrame 的 blob 分支同逻辑(含"content-type 要跟着搬"这条)
|
|
224
|
-
var visualBlob = URL.createObjectURL(new Blob([visualHtml], { type: 'text/html;charset=utf-8' }))
|
|
225
|
-
fetch(visualBlob).then(function (r) {
|
|
226
|
-
var type = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
|
|
227
|
-
return r.text().then(function (text) { return { text: text, type: type } })
|
|
228
|
-
}).then(function (loaded) {
|
|
229
|
-
var injected = bridgeIntoHtml(loaded.text)
|
|
230
|
-
var owned = URL.createObjectURL(new Blob([injected], { type: loaded.type }))
|
|
231
|
-
frameC.setAttribute('src', owned)
|
|
232
|
-
diag('C injected marker=' + (String(injected).indexOf('data-dsh-sel-bridge') > 0) + ' srcOwned=' + (frameC.getAttribute('src') === owned) + ' type=' + loaded.type)
|
|
233
|
-
}).catch(function (error) { diag('C inject threw ' + error) })
|
|
234
|
-
</script>
|
|
235
|
-
</body></html>`
|
|
236
|
-
|
|
237
|
-
// ───────────────────────── 跑浏览器并断言 ─────────────────────────
|
|
238
|
-
|
|
239
|
-
if (process.argv.includes('--dump')) {
|
|
240
|
-
process.stdout.write(page)
|
|
241
|
-
process.exit(0)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** 找一个能用的 Chrome/Chromium。 */
|
|
245
|
-
function findChrome() {
|
|
246
|
-
const candidates = [
|
|
247
|
-
process.env.CHROME,
|
|
248
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
249
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
250
|
-
'/usr/bin/google-chrome',
|
|
251
|
-
'/usr/bin/chromium',
|
|
252
|
-
'/usr/bin/chromium-browser',
|
|
253
|
-
].filter(Boolean)
|
|
254
|
-
for (const item of candidates) {
|
|
255
|
-
if (existsSync(item)) return item
|
|
256
|
-
}
|
|
257
|
-
return ''
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const chrome = findChrome()
|
|
261
|
-
if (!chrome) {
|
|
262
|
-
console.log('SKIP 没找到 Chrome/Chromium(设 CHROME=/path/to/chrome 可指定);页面可用 --dump 导出后手动跑')
|
|
263
|
-
process.exit(0)
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
let failed = 0
|
|
267
|
-
const assert = (label, ok, extra) => {
|
|
268
|
-
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
|
|
269
|
-
if (!ok) failed += 1
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// blob: 的导航在 file:// 下会被拒(父页面 origin 是 null),所以必须走 http 源
|
|
273
|
-
const server = createServer((req, res) => {
|
|
274
|
-
if (req.url && req.url.startsWith('/smoke.html')) {
|
|
275
|
-
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
276
|
-
res.end(page)
|
|
277
|
-
return
|
|
278
|
-
}
|
|
279
|
-
res.writeHead(404)
|
|
280
|
-
res.end('not found')
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
await new Promise((done) => server.listen(0, '127.0.0.1', done))
|
|
284
|
-
const port = server.address().port
|
|
285
|
-
|
|
286
|
-
function parseDump(dump) {
|
|
287
|
-
const matched = /<pre id="out">([\s\S]*?)<\/pre>/.exec(dump || '')
|
|
288
|
-
if (!matched) return null
|
|
289
|
-
try {
|
|
290
|
-
return JSON.parse(
|
|
291
|
-
matched[1].replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'),
|
|
292
|
-
)
|
|
293
|
-
} catch (error) {
|
|
294
|
-
return null
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/** 三个帧都报了才算这一趟跑齐(虚拟时钟下子框架的加载顺序有竞态)。 */
|
|
299
|
-
function complete(payload) {
|
|
300
|
-
if (!payload) return false
|
|
301
|
-
const seen = new Set(payload.msgs.filter((m) => m.kind === 'selection').map((m) => m.from))
|
|
302
|
-
return seen.has('A') && seen.has('B') && seen.has('C')
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
let payload = null
|
|
306
|
-
let dump = ''
|
|
307
|
-
try {
|
|
308
|
-
// 最多试 3 趟:--virtual-time-budget 下"帧的加载 vs 预算耗尽"有竞态(实测偶发某帧还没报就 dump 了),
|
|
309
|
-
// 重试是这里最省事又可靠的兜底。
|
|
310
|
-
for (let attempt = 1; attempt <= 3 && !complete(payload); attempt += 1) {
|
|
311
|
-
// 必须用**异步**的 execFile:同步版会堵住本进程的事件循环,
|
|
312
|
-
// 上面那个临时 http 服务就没法应答 Chrome 的请求 → 浏览器一直等(实测挂死)。
|
|
313
|
-
// 也不要给 --user-data-dir:本机 Chrome 在跑时,全新 profile 的 headless 会卡住不退出。
|
|
314
|
-
const result = await execFileAsync(
|
|
315
|
-
chrome,
|
|
316
|
-
['--headless', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--virtual-time-budget=20000', '--dump-dom', `http://127.0.0.1:${port}/smoke.html`],
|
|
317
|
-
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 60000 },
|
|
318
|
-
)
|
|
319
|
-
dump = result.stdout
|
|
320
|
-
payload = parseDump(dump)
|
|
321
|
-
}
|
|
322
|
-
} catch (error) {
|
|
323
|
-
console.log('(Chrome 调用失败:' + String((error && error.message) || error).slice(0, 200) + ')')
|
|
324
|
-
} finally {
|
|
325
|
-
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
|
|
326
|
-
server.close()
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
assert('浏览器里拿到了验证结果(<pre id="out">)', !!payload, dump.slice(0, 120))
|
|
330
|
-
if (payload) {
|
|
331
|
-
const fromA = payload.msgs.filter((m) => m.from === 'A' && m.kind === 'selection')
|
|
332
|
-
const fromB = payload.msgs.filter((m) => m.from === 'B' && m.kind === 'selection')
|
|
333
|
-
assert('基础预览(srcdoc + CSP script-src none):注入后桥跑起来了', payload.diag.some((d) => d.indexOf('A injected marker=true') === 0), payload.diag.join(' | '))
|
|
334
|
-
assert('基础预览:父页面收到帧内上报的选区', fromA.length > 0, JSON.stringify(payload.msgs.map((m) => m.from + ':' + m.kind)))
|
|
335
|
-
assert('基础预览:报的文字正确', !!fromA[0] && fromA[0].text === 'the migration ran long', fromA[0] && fromA[0].text)
|
|
336
|
-
assert('基础预览:上下文用【】标出选中部分', !!fromA[0] && fromA[0].context.indexOf('【the migration ran long】') > 0, fromA[0] && fromA[0].context)
|
|
337
|
-
assert('基础预览:上下文里没有页面脚本源码(跳过 script/style)', !!fromA[0] && fromA[0].context.indexOf('postMessage') < 0, fromA[0] && fromA[0].context)
|
|
338
|
-
assert('基础预览:坐标是帧内视口坐标(正数、有宽高)', !!fromA[0] && fromA[0].rect.w > 0 && fromA[0].rect.h > 0, JSON.stringify(fromA[0] && fromA[0].rect))
|
|
339
|
-
assert('交互式预览(blob bootstrap + document.write):桥扛住了 document.write', payload.diag.some((d) => d.indexOf('B injected marker=true') === 0), payload.diag.join(' | '))
|
|
340
|
-
assert('交互式预览:父页面收到帧内上报的选区', fromB.length > 0, JSON.stringify(payload.msgs.map((m) => m.from + ':' + m.kind)))
|
|
341
|
-
assert('交互式预览:上下文用【】标出选中部分', !!fromB[0] && fromB[0].context.indexOf('【') > 0 && fromB[0].context.indexOf('】') > 0, fromB[0] && fromB[0].context)
|
|
342
|
-
|
|
343
|
-
// C:「图解」那种帧(blob + allow-scripts、没有宿主标记)—— 别的插件在侧边栏渲染的生成网页
|
|
344
|
-
const fromC = payload.msgs.filter((m) => m.from === 'C' && m.kind === 'selection')
|
|
345
|
-
const seq = payload.seq || []
|
|
346
|
-
const midAt = seq.indexOf('raw:C:C-drag-mid')
|
|
347
|
-
const selAt = seq.indexOf('C:selection')
|
|
348
|
-
assert('「图解」那种 blob 帧:注入后桥也跑起来了', payload.diag.some((d) => d.indexOf('C injected marker=true') === 0), payload.diag.join(' | '))
|
|
349
|
-
assert('「图解」那种 blob 帧:父页面收到帧内上报的选区', fromC.length > 0, JSON.stringify(seq))
|
|
350
|
-
assert('「图解」帧:上下文用【】标出选中部分', !!fromC[0] && fromC[0].context.indexOf('【') > 0, fromC[0] && fromC[0].context)
|
|
351
|
-
assert(
|
|
352
|
-
'浮现时机:拖拽进行中(按住鼠标)不上报,松手后才报',
|
|
353
|
-
midAt >= 0 && selAt > midAt,
|
|
354
|
-
'seq=' + JSON.stringify(seq) + ' midAt=' + midAt + ' selAt=' + selAt,
|
|
355
|
-
)
|
|
356
|
-
// 消失时机:在网页里点一下 → 先 press(收浮标)→ 再 clear(选区没了)
|
|
357
|
-
const dragEndAt = seq.indexOf('raw:C:C-drag-end')
|
|
358
|
-
const pressAt = seq.lastIndexOf('C:press')
|
|
359
|
-
const clearAt = seq.indexOf('C:clear')
|
|
360
|
-
assert('消失时机:帧内按下会通知父页面收浮标(press)', pressAt > dragEndAt, 'seq=' + JSON.stringify(seq))
|
|
361
|
-
assert('消失时机:选区清空后发 clear', clearAt > pressAt, 'seq=' + JSON.stringify(seq))
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
console.log(`\n=== 划词桥浏览器冒烟:${failed === 0 ? '全部通过' : failed + ' 项失败'} ===`)
|
|
365
|
-
process.exit(failed === 0 ? 0 : 1)
|