dsh-wisp 0.6.0 → 1.25.1

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.
Files changed (68) hide show
  1. package/README.md +869 -10
  2. package/assets/canon/attn.webp +0 -0
  3. package/assets/canon/happy.webp +0 -0
  4. package/assets/canon/idle.webp +0 -0
  5. package/assets/canon/poked.webp +0 -0
  6. package/assets/canon/proud.webp +0 -0
  7. package/assets/canon/sleepy.webp +0 -0
  8. package/assets/canon/work.webp +0 -0
  9. package/assets/classic/attn.webp +0 -0
  10. package/assets/classic/happy.webp +0 -0
  11. package/assets/classic/idle.webp +0 -0
  12. package/assets/classic/poked.webp +0 -0
  13. package/assets/classic/proud.webp +0 -0
  14. package/assets/classic/sleepy.webp +0 -0
  15. package/assets/classic/work.webp +0 -0
  16. package/assets/deepsea/attn.webp +0 -0
  17. package/assets/deepsea/happy.webp +0 -0
  18. package/assets/deepsea/idle.webp +0 -0
  19. package/assets/deepsea/poked.webp +0 -0
  20. package/assets/deepsea/proud.webp +0 -0
  21. package/assets/deepsea/sleepy.webp +0 -0
  22. package/assets/deepsea/work.webp +0 -0
  23. package/assets/night/attn.webp +0 -0
  24. package/assets/night/happy.webp +0 -0
  25. package/assets/night/idle.webp +0 -0
  26. package/assets/night/poked.webp +0 -0
  27. package/assets/night/proud.webp +0 -0
  28. package/assets/night/sleepy.webp +0 -0
  29. package/assets/night/work.webp +0 -0
  30. package/assets/pajama/attn.webp +0 -0
  31. package/assets/pajama/happy.webp +0 -0
  32. package/assets/pajama/idle.webp +0 -0
  33. package/assets/pajama/poked.webp +0 -0
  34. package/assets/pajama/proud.webp +0 -0
  35. package/assets/pajama/sleepy.webp +0 -0
  36. package/assets/pajama/work.webp +0 -0
  37. package/audit/generation-audit.jsonl +35 -0
  38. package/build.mjs +93 -30
  39. package/cordis.patch.yml +2 -0
  40. package/lib/client.js +1293 -41
  41. package/lib/client.template.js +1248 -37
  42. package/lib/index.js +115 -12
  43. package/package.json +8 -1
  44. package/tools/CHARACTER-PROMPT.md +221 -3
  45. package/tools/assets.mjs +39 -16
  46. package/tools/audit-log.mjs +245 -0
  47. package/tools/bundle-cost.mjs +105 -0
  48. package/tools/pack-check.mjs +78 -0
  49. package/tools/perf.mjs +186 -0
  50. package/tools/smoke.mjs +577 -0
  51. package/tools/tiers.mjs +104 -0
  52. package/verify-wisp.mjs +2681 -872
  53. package/assets/happy.webp +0 -0
  54. package/assets/happy_hi.webp +0 -0
  55. package/assets/happy_md.webp +0 -0
  56. package/assets/happy_sm.webp +0 -0
  57. package/assets/idle.webp +0 -0
  58. package/assets/idle_hi.webp +0 -0
  59. package/assets/idle_md.webp +0 -0
  60. package/assets/idle_sm.webp +0 -0
  61. package/assets/sleepy.webp +0 -0
  62. package/assets/sleepy_hi.webp +0 -0
  63. package/assets/sleepy_md.webp +0 -0
  64. package/assets/sleepy_sm.webp +0 -0
  65. package/assets/work.webp +0 -0
  66. package/assets/work_hi.webp +0 -0
  67. package/assets/work_md.webp +0 -0
  68. package/assets/work_sm.webp +0 -0
@@ -0,0 +1,577 @@
1
+ /* ============================================================================
2
+ smoke.mjs — 真实浏览器冒烟测试。
3
+
4
+ 为什么需要它:`verify-wisp.mjs` 用假 DOM 跑 190+ 条断言,覆盖不到"真实布局与合成"这类
5
+ 行为。本项目的三个真 bug 全部只有真实浏览器能发现:
6
+ · 躲起来后气泡仍挂在屏幕上(假 DOM 里"移除了"就算过)
7
+ · 菜单越界(夹取用的是几何,假 DOM 没有布局)
8
+ · display:none 是否真的让她不挡点击
9
+ 所以这些检查必须留在仓库里,而不是散在开发机的工作区脚本中。
10
+
11
+ RUN
12
+ node tools/smoke.mjs
13
+ node tools/smoke.mjs --headed # 看着它跑
14
+
15
+ 浏览器是**可选依赖**:找不到就以 0 退出并说明原因(CI 上不该因为缺浏览器而红)。
16
+ ========================================================================== */
17
+
18
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
19
+ import { spawnSync } from 'node:child_process'
20
+ import { createRequire } from 'node:module'
21
+ import { dirname, join, resolve } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+
24
+ const here = resolve(dirname(fileURLToPath(import.meta.url)), '..')
25
+ const require = createRequire(import.meta.url)
26
+
27
+ const argv = process.argv.slice(2)
28
+ const headed = argv.includes('--headed')
29
+
30
+ /* ---- 可选依赖:Playwright 与一份 Chromium --------------------------------
31
+ pnpm 布局下顶层不一定有 `node_modules/playwright-core` 这个具体名字(只有
32
+ `.pnpm/playwright-core@<ver>/`),所以除了正常的 require 解析,还要扫一遍 .pnpm。 */
33
+ function findPlaywright() {
34
+ const bases = [
35
+ process.env.DSH_PROFILE_DIR ? join(process.env.DSH_PROFILE_DIR, 'package.json') : null,
36
+ join(here, 'package.json'),
37
+ 'E:/deepseek-harness/package.json',
38
+ ].filter((p) => p && existsSync(p))
39
+ for (const base of bases) {
40
+ for (const spec of ['playwright-core', 'playwright', 'playwright-chromium']) {
41
+ try {
42
+ // 必须**真的**有 chromium 才算命中:require 成功但导出里没有它的情况是存在的,
43
+ // 直接返回会让调用处拿到 undefined 再炸在别处(tools/perf.mjs 上就踩过一次)。
44
+ const mod = createRequire(base)(spec)
45
+ if (mod && mod.chromium) return { chromium: mod.chromium, from: `${base} → ${spec}` }
46
+ } catch (e) { /* 试下一个 */ }
47
+ }
48
+ }
49
+ const roots = [process.env.DSH_PROFILE_DIR ? join(process.env.DSH_PROFILE_DIR, '..') : null, here, 'E:/deepseek-harness']
50
+ .filter(Boolean)
51
+ for (const root of roots) {
52
+ const pnpm = join(root, 'node_modules', '.pnpm')
53
+ if (!existsSync(pnpm)) continue
54
+ for (const entry of readdirSync(pnpm)) {
55
+ if (!/^playwright(-core)?@/.test(entry)) continue
56
+ const pkg = join(pnpm, entry, 'node_modules', 'playwright-core')
57
+ if (!existsSync(pkg)) continue
58
+ try { return { chromium: require(pkg).chromium, from: pkg } } catch (e) { /* 下一个版本 */ }
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ const playwright = findPlaywright()
65
+ if (!playwright) {
66
+ console.log('smoke: 未找到 Playwright —— 跳过真实浏览器检查(这不是失败)')
67
+ console.log(' 安装后即可运行: npm i -D playwright-core (并准备一份 Chromium)')
68
+ process.exit(0)
69
+ }
70
+ const chromium = playwright.chromium
71
+
72
+ const chromeCandidates = [
73
+ process.env.WISP_CHROME,
74
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'ms-playwright', 'chromium-1234', 'chrome-win64', 'chrome.exe') : null,
75
+ '/usr/bin/chromium',
76
+ '/usr/bin/google-chrome',
77
+ ].filter(Boolean)
78
+ const executablePath = chromeCandidates.find((p) => existsSync(p))
79
+
80
+ /* 环境中断 vs 检查失败 —— 两者的退出码必须不同。
81
+
82
+ 这个套件里每一次真实问题都会以 FAIL 行出现;而"Target page, context or browser has
83
+ been closed"是**环境**中断:headless Chromium 在内存压力下会被关掉,与本插件的正确性
84
+ 无关。它的触发点随机(同一个脚本有时全过、有时在中途死掉),所以:
85
+ · 只对这类中断重试一次;
86
+ · 两次都中断就以 2 退出,而不是 1 —— 1 永远意味着"有检查没过"。
87
+ 不要因为它偶发就把断言放宽:那会把真正的产品问题一起放过。 */
88
+ const ENV_ABORT = /Target page|context or browser has been closed|browser has been closed|Target closed/i
89
+
90
+ function abortEnvironment(message) {
91
+ if (ENV_ABORT.test(message) && process.env.WISP_SMOKE_RETRY !== '1') {
92
+ console.error(`\nsmoke: 环境中断(浏览器被关闭),重试一次 —— ${message}`)
93
+ const child = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...argv], {
94
+ stdio: 'inherit',
95
+ env: { ...process.env, WISP_SMOKE_RETRY: '1' },
96
+ })
97
+ process.exit(child.status ?? 2)
98
+ }
99
+ console.error(`\nsmoke: aborted — ${message}`)
100
+ if (ENV_ABORT.test(message)) {
101
+ console.error('smoke: 两次都是环境中断:headless Chromium 在内存压力下被关闭,不是检查失败。')
102
+ }
103
+ process.exit(ENV_ABORT.test(message) ? 2 : 1)
104
+ }
105
+
106
+ process.on('uncaughtException', (error) => abortEnvironment(error?.stack ?? String(error)))
107
+ process.on('unhandledRejection', (error) => abortEnvironment(error?.stack ?? String(error)))
108
+
109
+ let pass = 0
110
+ let fail = 0
111
+ const ok = (label, detail = '') => { pass++; console.log(` PASS ${label}${detail ? ' — ' + detail : ''}`) }
112
+ const bad = (label, detail = '') => { fail++; console.log(` FAIL ${label}${detail ? ' — ' + detail : ''}`) }
113
+ const check = (cond, label, detail = '') => (cond ? ok(label, detail) : bad(label, detail))
114
+ const head = (t) => console.log(`\n=== ${t} ===`)
115
+
116
+ const bundle = readFileSync(join(here, 'lib', 'client.js'), 'utf8')
117
+
118
+ const browser = await chromium.launch({ headless: !headed, executablePath })
119
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
120
+
121
+ /* 一个最小的宿主页面:只有 __ModuleLoader__ 与客户端服务替身。
122
+ 整轮只用一个页面 —— 每个页面都要装下这份包与解码后的精灵图,多开一个页面就是
123
+ 多一份内存;之前正是在这里把浏览器推崩了(见文件末尾的内存记录)。 */
124
+ const mountPage = async (config = { wander: false }) => {
125
+ await page.setContent('<!doctype html><html><head></head><body><div style="height:80px">会话</div></body></html>')
126
+ await page.evaluate(({ src, config }) => {
127
+ const ctxFake = {
128
+ get: (n) => (n === 'timer'
129
+ ? {
130
+ timeout: (cb, ms) => { const i = setTimeout(cb, ms); return () => clearTimeout(i) },
131
+ interval: (cb, ms) => { const i = setInterval(cb, ms); return () => clearInterval(i) },
132
+ }
133
+ : n === 'styles'
134
+ ? { insert: (css) => { const s = document.createElement('style'); s.textContent = css; document.head.append(s); return () => s.remove() } }
135
+ : undefined),
136
+ on: () => () => {},
137
+ effect: () => () => {},
138
+ }
139
+ // 同一页里重新挂载:插件的单例守卫会先销毁上一个实例(并回收它的 blob),
140
+ // 所以不需要第二个页面来测"换一套配置从零开始"的行为。
141
+ window.__mountWisp = (cfg) => {
142
+ window.__ML = { def: null }
143
+ window.__ModuleLoader__ = { load: (d) => { window.__ML.def = d } }
144
+ new Function(src)()
145
+ return window.__ML.def.factory(() => ({})).apply(ctxFake, cfg)
146
+ }
147
+ window.__mountWisp(config)
148
+ }, { src: bundle, config })
149
+ await page.waitForTimeout(1200)
150
+ }
151
+
152
+ const state = () => page.evaluate(() => {
153
+ const root = document.querySelector('.wisp-root')
154
+ const layer = document.querySelector('.wisp-layer')
155
+ const se = document.scrollingElement
156
+ return {
157
+ mounted: root !== null,
158
+ mood: root?.dataset.mood ?? null,
159
+ hidden: window.__wisp?.hidden ?? null,
160
+ skin: window.__wisp?.skin ?? null,
161
+ position: window.__wisp?.position ?? null,
162
+ rootDisplay: root ? getComputedStyle(root).display : null,
163
+ rootArea: root ? Math.round(root.getBoundingClientRect().width * root.getBoundingClientRect().height) : 0,
164
+ layerChildren: layer ? layer.children.length : 0,
165
+ scrollable: se.scrollHeight > innerHeight || se.scrollWidth > innerWidth,
166
+ activeTag: document.activeElement?.tagName ?? null,
167
+ activeClass: document.activeElement?.className ?? null,
168
+ }
169
+ })
170
+
171
+ /* ------------------------------------------------------ 1. 挂载与覆盖层 --- */
172
+ head('1. mount and the overlay layer')
173
+
174
+ await mountPage()
175
+ let s = await state()
176
+ check(s.mounted, 'the companion mounts')
177
+ check(!s.scrollable, 'the page is not made scrollable by her layer', 'the 560x840 box must not extend the document')
178
+ check(s.rootDisplay !== 'none' && s.rootArea > 0, 'she is actually drawn', `${s.rootArea} px²`)
179
+
180
+ /* ------------------------------------------------------ 2. 右键菜单几何 --- */
181
+ head('2. context menu geometry and keyboard')
182
+
183
+ const openMenu = () => page.evaluate(() => {
184
+ const p = window.__wisp.position
185
+ const b = document.querySelector('.wisp-body').getBoundingClientRect()
186
+ document.querySelector('.wisp-body').dispatchEvent(new MouseEvent('contextmenu', {
187
+ bubbles: true, cancelable: true, clientX: b.x + b.width / 2, clientY: b.y + b.height / 2,
188
+ }))
189
+ const menu = document.querySelector('.wisp-menu')
190
+ if (!menu) return null
191
+ const r = menu.getBoundingClientRect()
192
+ return {
193
+ x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height),
194
+ inside: r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight,
195
+ items: [...menu.querySelectorAll('.wisp-menu-item')].map((i) => i.textContent),
196
+ }
197
+ })
198
+
199
+ /* 夹取:在她身体的下半部开菜单,那个位置按"不夹取"算会超出视口底边。
200
+ 候选点依次尝试 —— 这条断言要验的是**夹取**,不该被"某一点恰好落在她轮廓的
201
+ 透明区"这种无关因素干扰。同时断言 naive 位置确实越界:否则这条断言是空的
202
+ (菜单本来就在视口内,夹不夹取都过)。 */
203
+ const cornerMenu = await page.evaluate(() => {
204
+ const body = document.querySelector('.wisp-body')
205
+ const b = body.getBoundingClientRect()
206
+ const cands = [[0.5, 0.72], [0.46, 0.72], [0.54, 0.72], [0.5, 0.64], [0.44, 0.6], [0.56, 0.6]]
207
+ for (const [fx, fy] of cands) {
208
+ const x = b.x + b.width * fx
209
+ const y = b.y + b.height * fy
210
+ body.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y }))
211
+ const menu = document.querySelector('.wisp-menu')
212
+ if (!menu) continue
213
+ const r = menu.getBoundingClientRect()
214
+ return {
215
+ fx, fy,
216
+ pointerY: Math.round(y),
217
+ naiveBottom: Math.round(y + r.height),
218
+ bottom: Math.round(r.bottom),
219
+ right: Math.round(r.right),
220
+ inside: r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight,
221
+ }
222
+ }
223
+ return null
224
+ })
225
+ check(cornerMenu !== null, 'a menu can be opened near the bottom of her body',
226
+ cornerMenu ? `pointer at y=${cornerMenu.pointerY}` : 'no candidate point hit her silhouette')
227
+ check(cornerMenu !== null && cornerMenu.naiveBottom > 900,
228
+ 'the case actually needs clamping (otherwise the next check is vacuous)',
229
+ cornerMenu ? `unclamped bottom would be ${cornerMenu.naiveBottom} > 900` : 'n/a')
230
+ check(cornerMenu !== null && cornerMenu.inside,
231
+ 'and the menu is clamped back inside the viewport',
232
+ cornerMenu ? `bottom ${cornerMenu.bottom} (was ${cornerMenu.naiveBottom}), right ${cornerMenu.right}` : 'n/a')
233
+
234
+ const menu = await openMenu()
235
+ // 项数从皮肤数推导(13 项固定 + 每套皮肤一项),不写死:加一套皮肤不该让测试变红 ——
236
+ // 那是数据的错,不是行为的错。
237
+ // api.skins 是**数组**;用 Object.keys 数数组只是碰巧数对(预检里踩过这个坑,这里一并对齐)。
238
+ const skinCount = await page.evaluate(() => {
239
+ const s = window.__wisp.skins
240
+ return Array.isArray(s) ? s.length : Object.keys(s ?? {}).length
241
+ })
242
+ check(menu !== null && menu.items.length === 13 + skinCount, 'the menu lists every entry',
243
+ `${menu?.items.length} items = 13 + ${skinCount} skin(s)`)
244
+
245
+ // 键盘:打开即聚焦第一项,方向键移动,Esc 关闭并把焦点还给她
246
+ const kbd = await page.evaluate(() => {
247
+ const before = document.activeElement?.textContent ?? null
248
+ const press = (key) => window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
249
+ press('ArrowDown')
250
+ const afterDown = document.activeElement?.textContent ?? null
251
+ const tabbable = [...document.querySelectorAll('.wisp-menu-item')].filter((i) => i.tabIndex === 0).length
252
+ press('Escape')
253
+ return {
254
+ before, afterDown, tabbable,
255
+ menuGone: document.querySelector('.wisp-menu') === null,
256
+ focusAfter: document.activeElement?.className ?? null,
257
+ }
258
+ })
259
+ check(kbd.before === menu.items[0], 'opening the menu focuses the first item', String(kbd.before))
260
+ check(kbd.afterDown === menu.items[1], 'ArrowDown moves focus', String(kbd.afterDown))
261
+ check(kbd.tabbable === 1, 'roving tabindex keeps exactly one item tabbable', `${kbd.tabbable}`)
262
+ check(kbd.menuGone, 'Escape closes the menu')
263
+ check(kbd.focusAfter === 'wisp-body', 'and focus lands back on her, not on <body>', String(kbd.focusAfter))
264
+
265
+ // 点击菜单项要真的生效
266
+ const openedAgain = await openMenu()
267
+ check(openedAgain !== null, 'the menu reopens for the activation check')
268
+ const zoomed = await page.evaluate(() => {
269
+ const item = [...document.querySelectorAll('.wisp-menu-item')].find((i) => i.textContent === '放大一点')
270
+ if (!item) return { missing: true }
271
+ item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
272
+ return { width: document.querySelector('.wisp-root').style.width, menuGone: document.querySelector('.wisp-menu') === null }
273
+ })
274
+ check(zoomed.width === '630px' && zoomed.menuGone, 'a menu item runs and closes the menu',
275
+ zoomed.missing ? 'the item was not found' : `width=${zoomed.width}`)
276
+
277
+ /* 键盘入口:假 DOM 证明不了原生按键处理与事件冒泡(窗口上的捕获监听会先看到 keydown),
278
+ 所以这里用真实按键事件走一遍。 */
279
+ const focusHer = await page.evaluate(() => {
280
+ document.querySelector('.wisp-body').focus()
281
+ return document.activeElement?.className ?? null
282
+ })
283
+ check(focusHer === 'wisp-body', 'her body takes focus', String(focusHer))
284
+ await page.keyboard.press('Enter')
285
+ await page.waitForTimeout(250)
286
+ check(await page.evaluate(() => document.querySelector('.wisp-menu') !== null),
287
+ 'a real Enter key opens the menu', 'no pointer involved')
288
+ await page.keyboard.press('Escape')
289
+ await page.waitForTimeout(250)
290
+ const afterEscape = await page.evaluate(() => ({
291
+ menuGone: document.querySelector('.wisp-menu') === null,
292
+ focus: document.activeElement?.className ?? null,
293
+ }))
294
+ check(afterEscape.menuGone && afterEscape.focus === 'wisp-body',
295
+ 'and a real Escape closes it and hands focus back to her',
296
+ `menu gone=${afterEscape.menuGone}, focus=${afterEscape.focus}`)
297
+
298
+ /* ------------------------------------------------------ 3. 躲起来 --------- */
299
+ head('3. hide and the way back')
300
+
301
+ await page.evaluate(() => window.__wisp.say('测试气泡'))
302
+ await page.evaluate(() => window.__wisp.hide())
303
+ await page.waitForTimeout(250)
304
+ s = await state()
305
+ check(s.hidden === true && s.rootDisplay === 'none' && s.rootArea === 0,
306
+ 'hiding takes her out of the layout', `display=${s.rootDisplay} area=${s.rootArea}`)
307
+ const bubblesLeft = await page.evaluate(() => document.querySelectorAll('.wisp-say').length)
308
+ check(bubblesLeft === 0, 'and takes the speech bubble with her', `${bubblesLeft} left on screen`)
309
+ const tabBox = await page.evaluate(() => {
310
+ const t = document.querySelector('.wisp-tab')
311
+ if (!t) return null
312
+ const r = t.getBoundingClientRect()
313
+ return { w: Math.round(r.width), h: Math.round(r.height), inside: r.right <= innerWidth && r.bottom <= innerHeight }
314
+ })
315
+ check(tabBox !== null && tabBox.inside, 'a mini tab is left in the corner', JSON.stringify(tabBox))
316
+
317
+ await page.click('.wisp-tab')
318
+ await page.waitForTimeout(300)
319
+ s = await state()
320
+ check(s.hidden === false && s.rootDisplay !== 'none' && s.rootArea > 0, 'clicking the tab brings her back',
321
+ `display=${s.rootDisplay} area=${s.rootArea}`)
322
+
323
+ /* ------------------------------------------------------ 6. 状态反应 ------- */
324
+ head('6. reactions against the real DOM hooks')
325
+
326
+ /* attn / worried 都挂在 DSH 真实渲染出来的 data-* 属性上。假 DOM 里那是我自己造的选择器,
327
+ 只有真实页面才能证明"这个钩子确实存在、确实能被驱动"。 */
328
+
329
+ await page.evaluate(() => {
330
+ const panel = document.createElement('div')
331
+ panel.setAttribute('data-question-key', 'smoke-question-1')
332
+ panel.id = 'wisp-question'
333
+ document.body.append(panel)
334
+ })
335
+ await page.waitForTimeout(1600)
336
+ const attnState = await page.evaluate(() => ({
337
+ mood: document.querySelector('.wisp-root').dataset.mood,
338
+ bubble: document.querySelector('.wisp-say')?.textContent ?? null,
339
+ }))
340
+ check(attnState.mood === 'attn',
341
+ 'a [data-question-key] panel switches her to attention', `${attnState.mood} / 「${attnState.bubble}」`)
342
+
343
+ const attnRepeat = await page.evaluate(async () => {
344
+ const before = document.querySelectorAll('.wisp-say').length
345
+ await new Promise((r) => setTimeout(r, 2600))
346
+ return { before, after: document.querySelectorAll('.wisp-say').length }
347
+ })
348
+ check(attnRepeat.after <= attnRepeat.before,
349
+ 'she does not repeat herself for the same pending item', `${attnRepeat.before} -> ${attnRepeat.after}`)
350
+
351
+ await page.evaluate(() => document.getElementById('wisp-question').remove())
352
+ await page.waitForTimeout(1600)
353
+ check(await page.evaluate(() => document.querySelector('.wisp-root').dataset.mood) === 'idle',
354
+ 'answering it lets her relax')
355
+
356
+ // 一轮跑完 + 这一轮有失败行 → worried
357
+ await page.evaluate(() => {
358
+ const failed = document.createElement('span')
359
+ failed.setAttribute('data-error', 'true')
360
+ failed.id = 'wisp-error'
361
+ failed.textContent = 'a failed tool row'
362
+ document.body.append(failed)
363
+ const stop = document.createElement('button')
364
+ stop.setAttribute('aria-label', '停止')
365
+ stop.id = 'wisp-stop'
366
+ document.body.append(stop)
367
+ })
368
+ await page.waitForTimeout(1600)
369
+ const whileBusy = await page.evaluate(() => document.querySelector('.wisp-root').dataset.mood)
370
+ check(whileBusy === 'alert', 'a running agent puts her on alert', String(whileBusy))
371
+ await page.evaluate(() => document.getElementById('wisp-stop').remove())
372
+ await page.waitForTimeout(1600)
373
+ const worriedState = await page.evaluate(() => ({
374
+ mood: document.querySelector('.wisp-root').dataset.mood,
375
+ bubble: document.querySelector('.wisp-say')?.textContent ?? null,
376
+ }))
377
+ check(worriedState.mood === 'worried',
378
+ 'a finished run with a failed row puts her in the worried state',
379
+ `${worriedState.mood} / 「${worriedState.bubble}」`)
380
+ await page.waitForTimeout(4200)
381
+ check(await page.evaluate(() => document.querySelector('.wisp-root').dataset.mood) === 'idle',
382
+ 'the worried look expires on its own')
383
+
384
+ /* ------------------------------------------------------ 7. 体积与自包含 --- */
385
+ head('7. shipping shape')
386
+
387
+ const bundleKb = Math.round(Buffer.byteLength(bundle) / 1024)
388
+ /* 这里**只记录体积,不设上限**。
389
+ 曾经有过 4 MB / 6 MB 两条上限,都是我为了"别让它悄悄变胖"自己拍的数 —— 不是任何真实约束。
390
+ 后来把它量清楚了:24 MB 的包解析+执行 46.5 ms,斜率约 1.45 ms/MB(次线性),
391
+ 再加一整套皮肤(+1.1 MB)约 +1.6 ms。**代价小到不值得为它设一条会挡住功能的红线。**
392
+ 真正该守的是"代价"而不是"体积":挂载开销由 tools/perf.mjs 量并写进 README。 */
393
+ check(bundleKb > 0, 'bundle size is recorded', `${bundleKb} KB`)
394
+ check(!/["'][^"']*\.(webp|png)["']/.test(bundle), 'the bundle references no external image files')
395
+ const scrolling = await page.evaluate(() => document.scrollingElement.scrollHeight > innerHeight)
396
+ check(!scrolling, 'still no page scrollbar after everything above')
397
+
398
+ /* 解码后的显存/内存占用:一张 2048x3072 的精灵图在内存里是 2048*3072*4 = 25 MB。
399
+ 本轮真实浏览器测试就是被这个推崩的(多开一个页面 = 多一份解码结果)。
400
+ 这里量出来并记录,因为它是"选哪一档"的一个真实约束 —— 不只是锐度。 */
401
+ const memory = await page.evaluate(async () => {
402
+ const w = window.__wisp
403
+ const measure = async () => {
404
+ if (!performance.memory) return null
405
+ if (typeof gc === 'function') gc()
406
+ return Math.round(performance.memory.usedJSHeapSize / 1024 / 1024)
407
+ }
408
+ const before = await measure()
409
+ w.setSkin(w.skin === 'deepsea' ? 'classic' : 'deepsea')
410
+ await new Promise((r) => setTimeout(r, 900))
411
+ const after = await measure()
412
+ const img = document.querySelector('.wisp-img')
413
+ return { before, after, decodedPerSprite: img.naturalWidth * img.naturalHeight * 4 / 1024 / 1024 }
414
+ })
415
+ check(memory.decodedPerSprite > 0, 'decoded sprite size is recorded',
416
+ `${memory.decodedPerSprite.toFixed(1)} MB per sprite` +
417
+ (memory.before !== null ? `(JS 堆 ${memory.before} -> ${memory.after} MB)` : '(本浏览器不暴露 performance.memory)'))
418
+ check(memory.decodedPerSprite < 40, 'a single decoded sprite stays under 40 MB',
419
+ `${memory.decodedPerSprite.toFixed(1)} MB — the hi tier would be 4x this`)
420
+
421
+ /* ------------------------------------------------------ doctor() ---------- */
422
+ head('8. doctor() against a real DOM')
423
+
424
+ const doctorBefore = await page.evaluate(() => window.__wisp.doctor())
425
+ check(doctorBefore.hooks.composer.every((h) => h.matches === 0),
426
+ 'on a page with no composer it correctly finds no composer hook',
427
+ doctorBefore.hooks.composer.map((h) => `${h.selector}=${h.matches}`).join(' '))
428
+ check(doctorBefore.problems.some((p) => p.includes('输入框')),
429
+ 'and names the feature that would go quiet, instead of just reporting a number',
430
+ doctorBefore.problems.join(' / '))
431
+ check(doctorBefore.tokens.every((t) => t.value === '') && doctorBefore.problems.some((p) => p.includes('主题令牌')),
432
+ 'without the app stylesheet it reports the empty theme tokens as a problem',
433
+ `令牌全空=${doctorBefore.tokens.every((t) => t.value === '')},problems=${doctorBefore.problems.length}`)
434
+
435
+ // 放一个输入框进去:诊断必须跟着变 —— 这条才证明它读的是**当前** DOM,不是缓存
436
+ await page.evaluate(() => {
437
+ const box = document.createElement('textarea')
438
+ box.id = 'smoke-composer'
439
+ document.body.append(box)
440
+ })
441
+ await page.waitForTimeout(150)
442
+ const doctorAfter = await page.evaluate(() => window.__wisp.doctor())
443
+ check(doctorAfter.hooks.composer.some((h) => h.matches > 0),
444
+ 'and once a composer exists it is found',
445
+ doctorAfter.hooks.composer.map((h) => `${h.selector}=${h.matches}`).join(' '))
446
+ check(!doctorAfter.problems.some((p) => p.includes('输入框')),
447
+ 'so the warning clears', String(doctorAfter.problems.length) + ' problem(s) left')
448
+
449
+ /* 把 README 的性能声明钉成断言:以后谁再加一个"每轮都查一次"的功能,这里会先红,
450
+ 而不是等 README 悄悄过期。 */
451
+ const idleQueries = await page.evaluate(async () => {
452
+ const q = { sel: 0, all: 0 }
453
+ const q1 = Document.prototype.querySelector
454
+ const q2 = Document.prototype.querySelectorAll
455
+ Document.prototype.querySelector = function (s) { q.sel++; return q1.call(this, s) }
456
+ Document.prototype.querySelectorAll = function (s) { q.all++; return q2.call(this, s) }
457
+ const started = performance.now()
458
+ await new Promise((r) => setTimeout(r, 6000))
459
+ const seconds = (performance.now() - started) / 1000
460
+ Document.prototype.querySelector = q1
461
+ Document.prototype.querySelectorAll = q2
462
+ return { seconds, sel: q.sel, all: q.all }
463
+ })
464
+ const selPerSec = idleQueries.sel / idleQueries.seconds
465
+ const allPerSec = idleQueries.all / idleQueries.seconds
466
+ check(selPerSec <= 4, 'idle DOM queries stay around two per poll',
467
+ `${selPerSec.toFixed(2)} 次/秒(每 1.2s 一轮:忙碌 + 待处理 + 输入框)`)
468
+ check(allPerSec === 0, 'and no full-document scans while idle',
469
+ `${allPerSec.toFixed(2)} 次/秒 —— 出错扫描只在跑完一轮时发生`)
470
+
471
+ /* 换皮肤放在最后:它是唯一会解码**第二套**精灵图的操作(一套 5 张 × 6 MB)。
472
+ 放在反应段之前,会让后面所有断言都在峰值内存下跑 —— 之前的随机中断就是在这里之后发生的。 */
473
+ /* ------------------------------------------------------ 4. 换皮肤 --------- */
474
+ head('4. skins swap the actual sprite')
475
+
476
+ const skinSwap = await page.evaluate(async () => {
477
+ const w = window.__wisp
478
+ const before = { skin: w.skin, src: document.querySelector('.wisp-img').src, natural: document.querySelector('.wisp-img').naturalWidth }
479
+ /* 目标皮肤**动态挑一个和当前不同的**:前面几段可能已经切过皮肤(菜单那段就会切),
480
+ 写死 'classic' 会变成"切到自己"的空操作 —— 断言于是永远看不到换图。 */
481
+ /* w.skins 是**数组**(api 刻意返回 SKIN_IDS.slice(),防止外部改动),不是以 id 为键的对象。
482
+ 之前用 Object.keys() 拿到的是 ['0','1','2','3'],setSkin('0') 被正确拒绝 —— 断言于是停在原地。 */
483
+ const ids = Array.isArray(w.skins) ? w.skins.slice() : Object.keys(w.skins || {})
484
+ const target = ids.find((id) => id !== w.skin) || w.skin
485
+ w.setSkin(target)
486
+ await new Promise((r) => setTimeout(r, 700))
487
+ const img = document.querySelector('.wisp-img')
488
+ return {
489
+ target,
490
+ ids,
491
+ afterSkin: w.skin,
492
+ before,
493
+ after: { skin: w.skin, src: img.src, natural: img.naturalWidth },
494
+ // 交叉淡入淡出期间应有第二层
495
+ layersDuringFade: document.querySelectorAll('.wisp-img').length,
496
+ }
497
+ })
498
+ check(skinSwap.after.skin === skinSwap.target && skinSwap.after.src !== skinSwap.before.src,
499
+ 'switching skins loads a different image',
500
+ `${skinSwap.before.skin} -> ${skinSwap.after.skin};候选 ${JSON.stringify(skinSwap.ids)},目标 ${skinSwap.target}`)
501
+ check(skinSwap.after.natural > 0, 'the swapped sprite actually decodes', `${skinSwap.after.natural}px wide`)
502
+ check(skinSwap.layersDuringFade === 1, 'the cross-fade layer is gone once it settles', `${skinSwap.layersDuringFade} img layer(s)`)
503
+
504
+
505
+ // 先收掉这个浏览器再开下一个:**每个浏览器只驻留一个页面**。
506
+ // 每个页面都要装下这份包与解码后的精灵图(单张就是 naturalWidth*naturalHeight*4 字节),
507
+ // 多开一个页面 = 多一份;之前正是这样把浏览器推崩的,表现为随机的
508
+ // "Target page, context or browser has been closed",极难定位。
509
+ await browser.close()
510
+
511
+ /* ------------------------------------------------------ 8. 自己踱步 ------- */
512
+ head('9. idle wander')
513
+
514
+ const wanderBrowser = await chromium.launch({ headless: !headed, executablePath })
515
+ const wanderPage = await wanderBrowser.newPage({ viewport: { width: 1440, height: 900 } })
516
+ const wanderLogs = []
517
+ wanderPage.on('console', (msg) => { if (msg.type() === 'error') wanderLogs.push(msg.text()) })
518
+ wanderPage.on('pageerror', (err) => wanderLogs.push('pageerror: ' + err.message))
519
+ await wanderPage.setContent('<!doctype html><html><head></head><body></body></html>')
520
+ await wanderPage.evaluate((src) => {
521
+ window.__ML = { def: null }
522
+ window.__ModuleLoader__ = { load: (d) => { window.__ML.def = d } }
523
+ new Function(src)()
524
+ window.__ML.def.factory(() => ({})).apply({
525
+ get: (n) => (n === 'timer'
526
+ ? { timeout: (cb, ms) => { const i = setTimeout(cb, ms); return () => clearTimeout(i) }, interval: (cb, ms) => { const i = setInterval(cb, ms); return () => clearInterval(i) } }
527
+ : n === 'styles' ? { insert: (css) => { const s = document.createElement('style'); s.textContent = css; document.head.append(s); return () => s.remove() } } : undefined),
528
+ on: () => () => {}, effect: () => () => {},
529
+ }, { wander: true, wanderMs: 5000, wanderRange: 200, reactions: false })
530
+ }, bundle)
531
+ await wanderPage.waitForTimeout(1600)
532
+
533
+ const wanderMounted = await wanderPage.evaluate(() =>
534
+ typeof window.__wisp === 'object' && window.__wisp !== null && typeof window.__wisp.move === 'function')
535
+ check(wanderMounted, 'a wander-enabled mount comes up',
536
+ wanderMounted ? 'ok' : `no handle; console: ${wanderLogs.join(' | ') || '(empty)'}`)
537
+
538
+ const wander = wanderMounted
539
+ ? await wanderPage.evaluate(async () => {
540
+ const w = window.__wisp
541
+ w.move(400, 200) // 离边角远一点,任何方向都走得动
542
+ const before = { x: w.position.x, y: w.position.y }
543
+ let gliding = false
544
+ let prev = { ...before }
545
+ let maxStep = 0 // 单次踱步的位移
546
+ let steps = 0
547
+ const started = Date.now()
548
+ while (Date.now() - started < 11000) {
549
+ if (document.querySelector('.wisp-root').dataset.gliding === 'true') gliding = true
550
+ await new Promise((r) => setTimeout(r, 120))
551
+ const now = w.position
552
+ const d = Math.hypot(now.x - prev.x, now.y - prev.y)
553
+ // 踱步间隔 5s,远大于 120ms 的采样间隔,所以每一步都能被单独看到
554
+ if (d > 1) { steps++; maxStep = Math.max(maxStep, d) }
555
+ prev = now
556
+ }
557
+ return { before, after: prev, gliding, steps, maxStep }
558
+ })
559
+ : { before: { x: 0, y: 0 }, after: { x: 0, y: 0 }, gliding: false, steps: 0, maxStep: -1 }
560
+ check(wanderMounted && wander.steps > 0, 'she strolls on her own while idle',
561
+ `${wander.steps} stroll(s) from ${wander.before.x},${wander.before.y} to ${wander.after.x},${wander.after.y}`)
562
+ // 上限的【精确】断言在确定性 harness 里(verify-wisp.mjs 的 "a stroll stays inside the
563
+ // configured range")。这里只能验"她真的动了、而且用滑行",不能验精确上限:
564
+ // headless 页面被节流后定时器会补发,两次踱步会挤进同一个采样间隔被合成一步
565
+ // (实测最大单步 226px —— 插件的公式是 d·√(cos²+0.36·sin²) ≤ d,不可能超)。
566
+ check(wanderMounted && wander.maxStep <= 400, 'no observed step exceeds twice the range',
567
+ `最大单步 ${Math.round(wander.maxStep)}px(上限的精确断言在 verify-wisp.mjs)`)
568
+ check(wanderMounted && wander.gliding, 'and it is a glide, not a teleport', 'data-gliding was observed during the stroll')
569
+ await wanderBrowser.close()
570
+
571
+
572
+
573
+ console.log(`\n${fail === 0 ? '=== SMOKE PASSED ===' : '=== SMOKE FAILED ==='}`)
574
+ console.log(` ${pass} passed, ${fail} failed (real Chromium, ${headed ? 'headed' : 'headless'})`)
575
+ console.log(` playwright: ${playwright.from}`)
576
+ if (executablePath) console.log(` browser: ${executablePath}`)
577
+ process.exit(fail === 0 ? 0 : 1)