dsh-wisp 1.36.0 → 1.42.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.
- package/README.md +372 -7
- package/cordis.patch.yml +14 -19
- package/lib/client.js +629 -20
- package/lib/client.template.js +629 -20
- package/lib/index.js +211 -29
- package/package.json +2 -1
- package/tools/engine-probe.mjs +421 -0
- package/tools/smoke.mjs +272 -0
- package/verify-wisp.mjs +4457 -3683
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/* ---------------------------------------------------------------------------
|
|
2
|
+
* 真实引擎探针(不需要 Playwright)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么存在:`tools/smoke.mjs` 的能力上限就是 Playwright 有没有装 —— 没装时它整份
|
|
5
|
+
* 跳过(这是刻意的:CI 不该因为缺浏览器而红)。可**假 DOM 验不了 CSS**:关键帧里的
|
|
6
|
+
* `calc()`/`var()` 有没有真的解析、`rotate()` 有没有真的转、媒体查询有没有真的生效,
|
|
7
|
+
* 只有真引擎能给答案。所以这台机器上"预检全过 + 冒烟跳过"曾经等于**没有任何视觉验证**。
|
|
8
|
+
*
|
|
9
|
+
* 这个探针直接用 Node 自带的 fetch + WebSocket 走 CDP,驱动机器上已有的
|
|
10
|
+
* Edge / Chrome:零依赖、零安装,找到浏览器就跑,找不到就说清楚然后以 0 退出。
|
|
11
|
+
*
|
|
12
|
+
* 它验的是"引擎到底认不认",不是几何与布局(那是 smoke.mjs 的活):
|
|
13
|
+
* 1. 结构:body > motion > lean > sprite 真的在页面上
|
|
14
|
+
* 2. `rotate(var(--wisp-tilt))` 真的转(读计算后的 matrix,不是读源码)
|
|
15
|
+
* 3. `wisp-press` / `wisp-land` 真的在跑(getAnimations),而且关键帧**解析后有效**
|
|
16
|
+
* —— 动画"在跑"不等于有效:calc() 写错时浏览器会静默丢掉那一条 keyframe,
|
|
17
|
+
* 动画照跑但什么都不动。这条只有把 getKeyframes() 读出来才能证明。
|
|
18
|
+
* 4. 动作幅度档:`静止` 下计算后的 animationName 是 none,姿势被钉在 0
|
|
19
|
+
* 5. `prefers-reduced-motion`(CDP Emulation):姿势与动作都不写
|
|
20
|
+
*
|
|
21
|
+
* 用法:node tools/engine-probe.mjs (WISP_CHROME=<可执行文件> 可指定浏览器)
|
|
22
|
+
* 退出码:0 通过或跳过 · 1 有检查没过 · 2 环境起不来(找不到可用的调试端口)
|
|
23
|
+
* ------------------------------------------------------------------------- */
|
|
24
|
+
import { spawn } from 'node:child_process'
|
|
25
|
+
import { readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'
|
|
26
|
+
import { tmpdir } from 'node:os'
|
|
27
|
+
import { dirname, join, resolve } from 'node:path'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
|
|
30
|
+
const here = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
31
|
+
const bundle = readFileSync(join(here, 'lib', 'client.js'), 'utf8')
|
|
32
|
+
|
|
33
|
+
/* 浏览器从哪来:环境变量优先,然后按平台猜几个常见位置。
|
|
34
|
+
找不到就跳过 —— 这台机器没浏览器不是这个插件的缺陷。 */
|
|
35
|
+
const candidates = [
|
|
36
|
+
process.env.WISP_CHROME,
|
|
37
|
+
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
38
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
39
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
40
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
41
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
42
|
+
process.env.HOME && join(process.env.HOME, '.cache', 'ms-playwright', 'chromium-1234', 'chrome-linux', 'chrome'),
|
|
43
|
+
'/usr/bin/chromium',
|
|
44
|
+
'/usr/bin/chromium-browser',
|
|
45
|
+
'/usr/bin/google-chrome',
|
|
46
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
47
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
48
|
+
].filter(Boolean)
|
|
49
|
+
const executablePath = candidates.find((p) => existsSync(p))
|
|
50
|
+
|
|
51
|
+
if (!executablePath) {
|
|
52
|
+
console.log('engine-probe: 这台机器上没找到 Edge / Chrome —— 跳过真实引擎检查(这不是失败)')
|
|
53
|
+
console.log(' 指定一个:WISP_CHROME=<可执行文件> node tools/engine-probe.mjs')
|
|
54
|
+
process.exit(0)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const PORT = Number(process.env.WISP_CDP_PORT ?? 9333)
|
|
58
|
+
const profile = mkdtempSync(join(tmpdir(), 'wisp-probe-'))
|
|
59
|
+
const child = spawn(executablePath, [
|
|
60
|
+
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-extensions',
|
|
61
|
+
/* 视口要显式给:默认 headless 窗口只有 800x600,而她是 560x840 —— 点位全在屏幕外,
|
|
62
|
+
elementFromPoint 会返回 null 让检查变成假红(这条真踩过)。 */
|
|
63
|
+
'--window-size=1920,1200',
|
|
64
|
+
`--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, 'about:blank',
|
|
65
|
+
], { stdio: 'ignore' })
|
|
66
|
+
|
|
67
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
68
|
+
|
|
69
|
+
let failures = 0
|
|
70
|
+
const check = (cond, label, detail = '') => {
|
|
71
|
+
console.log(` ${cond ? 'PASS' : 'FAIL'} ${label}${detail ? ' — ' + detail : ''}`)
|
|
72
|
+
if (!cond) failures++
|
|
73
|
+
}
|
|
74
|
+
const head = (title) => console.log(`\n=== ${title} ===`)
|
|
75
|
+
|
|
76
|
+
const cleanup = async () => {
|
|
77
|
+
try { child.kill() } catch (e) { /* ignore */ }
|
|
78
|
+
await sleep(300)
|
|
79
|
+
try { rmSync(profile, { recursive: true, force: true }) } catch (e) { /* ignore */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const targets = await (async () => {
|
|
83
|
+
for (let i = 0; i < 60; i++) {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/json/list`)
|
|
86
|
+
if (res.ok) return await res.json()
|
|
87
|
+
} catch (e) { /* 还没起来 */ }
|
|
88
|
+
await sleep(250)
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
})()
|
|
92
|
+
|
|
93
|
+
if (targets === null) {
|
|
94
|
+
await cleanup()
|
|
95
|
+
console.error(`engine-probe: 浏览器的调试端口没有起来(${executablePath})—— 环境问题,不是检查失败`)
|
|
96
|
+
process.exit(2)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const page = targets.find((t) => t.type === 'page')
|
|
100
|
+
if (!page) {
|
|
101
|
+
await cleanup()
|
|
102
|
+
console.error('engine-probe: 没有可用的页面目标 —— 环境问题')
|
|
103
|
+
process.exit(2)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
|
107
|
+
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject })
|
|
108
|
+
|
|
109
|
+
let seq = 0
|
|
110
|
+
const pendingCalls = new Map()
|
|
111
|
+
ws.onmessage = (ev) => {
|
|
112
|
+
const msg = JSON.parse(ev.data)
|
|
113
|
+
if (!msg.id || !pendingCalls.has(msg.id)) return
|
|
114
|
+
const { resolve, reject } = pendingCalls.get(msg.id)
|
|
115
|
+
pendingCalls.delete(msg.id)
|
|
116
|
+
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
|
117
|
+
else resolve(msg.result)
|
|
118
|
+
}
|
|
119
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
120
|
+
const id = ++seq
|
|
121
|
+
pendingCalls.set(id, { resolve, reject })
|
|
122
|
+
ws.send(JSON.stringify({ id, method, params }))
|
|
123
|
+
})
|
|
124
|
+
const evaluate = async (expression) => {
|
|
125
|
+
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
|
|
126
|
+
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? 'evaluate failed')
|
|
127
|
+
return r.result.value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await send('Runtime.enable')
|
|
132
|
+
await send('Page.enable')
|
|
133
|
+
|
|
134
|
+
console.log(`engine-probe: ${executablePath}`)
|
|
135
|
+
|
|
136
|
+
head('mount')
|
|
137
|
+
const mounted = await evaluate(`(() => {
|
|
138
|
+
const src = ${JSON.stringify(bundle)}
|
|
139
|
+
document.body.innerHTML = '<div style="height:80px">会话</div>' +
|
|
140
|
+
'<div id="under" style="position:fixed;inset:0;z-index:0;background:#eef">' +
|
|
141
|
+
'<button id="underBtn" style="position:absolute;left:0;top:0;width:100%;height:100%;opacity:.02">under</button></div>'
|
|
142
|
+
window.__clicks = 0
|
|
143
|
+
document.getElementById('underBtn').addEventListener('click', () => { window.__clicks++ })
|
|
144
|
+
window.__ML = { def: null }
|
|
145
|
+
window.__ModuleLoader__ = { load: (d) => { window.__ML.def = d } }
|
|
146
|
+
new Function(src)()
|
|
147
|
+
window.__ML.def.factory(() => ({})).apply({
|
|
148
|
+
get: (n) => (n === 'timer'
|
|
149
|
+
? { timeout: (cb, ms) => { const i = setTimeout(cb, ms); return () => clearTimeout(i) },
|
|
150
|
+
interval: (cb, ms) => { const i = setInterval(cb, ms); return () => clearInterval(i) } }
|
|
151
|
+
: n === 'styles' ? { insert: (css) => { const s = document.createElement('style'); s.textContent = css; document.head.append(s); return () => s.remove() } } : undefined),
|
|
152
|
+
on: () => () => {}, effect: () => () => {},
|
|
153
|
+
}, { wander: false, reactions: false, sleepAfterMs: 3600000, persist: false })
|
|
154
|
+
const motion = document.querySelector('.wisp-motion')
|
|
155
|
+
const lean = document.querySelector('.wisp-lean')
|
|
156
|
+
return {
|
|
157
|
+
version: window.__wisp.version,
|
|
158
|
+
nesting: motion?.parentNode === document.querySelector('.wisp-body') && lean?.parentNode === motion,
|
|
159
|
+
imgInsideLean: lean?.querySelectorAll('.wisp-img').length,
|
|
160
|
+
}
|
|
161
|
+
})()`)
|
|
162
|
+
check(mounted.version !== undefined, 'the real bundle mounts', String(mounted.version))
|
|
163
|
+
check(mounted.nesting === true && mounted.imgInsideLean === 1,
|
|
164
|
+
'body > motion > lean > sprite is the live tree', JSON.stringify(mounted))
|
|
165
|
+
|
|
166
|
+
/* 命中判定:她那个 560x840 的盒子里**大部分是透明的**(实测抽样:只有约四分之一
|
|
167
|
+
的格是她)。透明处必须整层穿透到下面的应用 —— 这件事假 DOM 验不了(它没有命中
|
|
168
|
+
判定),只有真引擎能给答案:`elementFromPoint` 会同时考虑 pointer-events 与
|
|
169
|
+
clip-path,再用 CDP 发一次真实点击看下面那个按钮收不收得到。 */
|
|
170
|
+
head('the hit area is her silhouette, not her box')
|
|
171
|
+
await evaluate(`window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))`)
|
|
172
|
+
/* 这一段会把她挪到确定位置来采样;**结束时必须放回原处** —— 她的左右半边是镜像的,
|
|
173
|
+
留她在左半边会让后面所有"侧倾方向"的断言整体反号(这条真踩过)。 */
|
|
174
|
+
const wasAt = await evaluate('window.__wisp.position')
|
|
175
|
+
const picks = await evaluate(`(() => {
|
|
176
|
+
window.__wisp.move(600, 150)
|
|
177
|
+
const img = document.querySelector('.wisp-img')
|
|
178
|
+
const c = document.createElement('canvas'); c.width = 96; c.height = 144
|
|
179
|
+
const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, 96, 144)
|
|
180
|
+
const data = ctx.getImageData(0, 0, 96, 144).data
|
|
181
|
+
const p = window.__wisp.position
|
|
182
|
+
const clear = [], solid = []
|
|
183
|
+
for (let row = 2; row < 142; row += 4) {
|
|
184
|
+
for (let col = 2; col < 94; col += 4) {
|
|
185
|
+
const a = data[(row * 96 + col) * 4 + 3]
|
|
186
|
+
const x = Math.round(p.x + (col + 0.5) * (560 / 96))
|
|
187
|
+
const y = Math.round(p.y + (row + 0.5) * (840 / 144))
|
|
188
|
+
if (a === 0) clear.push({ x, y })
|
|
189
|
+
else if (a > 240) solid.push({ x, y })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const hitEl = document.querySelector('.wisp-hit')
|
|
193
|
+
return {
|
|
194
|
+
clear, solid,
|
|
195
|
+
hasHitLayer: hitEl !== null,
|
|
196
|
+
clip: hitEl ? String(hitEl.style.clipPath).slice(0, 0) + String(getComputedStyle(hitEl).clipPath).slice(0, 48) : null,
|
|
197
|
+
bodyPointerEvents: getComputedStyle(document.querySelector('.wisp-body')).pointerEvents,
|
|
198
|
+
}
|
|
199
|
+
})()`)
|
|
200
|
+
check(picks.hasHitLayer && picks.bodyPointerEvents === 'none',
|
|
201
|
+
'the body stops taking pointer events and a dedicated hit layer takes them',
|
|
202
|
+
`hitLayer=${picks.hasHitLayer} body=${picks.bodyPointerEvents}`)
|
|
203
|
+
check(String(picks.clip).startsWith('path('),
|
|
204
|
+
'the hit layer is clipped to the sprite silhouette (the browser does the pixel test)',
|
|
205
|
+
String(picks.clip) + '…')
|
|
206
|
+
check(picks.clear.length > picks.solid.length,
|
|
207
|
+
'and most of her box really is transparent — which is why a box-shaped hit area is wrong',
|
|
208
|
+
`${picks.solid.length} opaque samples vs ${picks.clear.length} transparent`)
|
|
209
|
+
|
|
210
|
+
const clearPoint = picks.clear[Math.floor(picks.clear.length / 2)]
|
|
211
|
+
const solidPoint = picks.solid[Math.floor(picks.solid.length / 2)]
|
|
212
|
+
const topAt = async (x, y) => evaluate(`(() => { const el = document.elementFromPoint(${x}, ${y}); return el ? (el.id || el.className || el.tagName) : null })()`)
|
|
213
|
+
const realClick = async (x, y) => {
|
|
214
|
+
const before = await evaluate('window.__clicks')
|
|
215
|
+
await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' })
|
|
216
|
+
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
|
|
217
|
+
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
|
|
218
|
+
await sleep(150)
|
|
219
|
+
return (await evaluate('window.__clicks')) - before
|
|
220
|
+
}
|
|
221
|
+
const clearTop = await topAt(clearPoint.x, clearPoint.y)
|
|
222
|
+
check(clearTop === 'underBtn' || clearTop === 'under',
|
|
223
|
+
'at a transparent pixel the topmost element is the APP, not her', String(clearTop))
|
|
224
|
+
const clearClicks = await realClick(clearPoint.x, clearPoint.y)
|
|
225
|
+
check(clearClicks === 1, 'a real click there reaches the app underneath', `${clearClicks} click(s)`)
|
|
226
|
+
const movedByClearClick = await evaluate('(() => { const p = window.__wisp.position; return p.x !== 600 || p.y !== 150 })()')
|
|
227
|
+
check(movedByClearClick === false, 'and it does not count as touching her')
|
|
228
|
+
|
|
229
|
+
const solidTop = await topAt(solidPoint.x, solidPoint.y)
|
|
230
|
+
check(solidTop === 'wisp-hit', 'on her body the topmost element is her hit layer', String(solidTop))
|
|
231
|
+
const solidClicks = await realClick(solidPoint.x, solidPoint.y)
|
|
232
|
+
check(solidClicks === 0, 'and clicking her does NOT fall through to the app', `${solidClicks} click(s)`)
|
|
233
|
+
const focusedAfterClick = await evaluate(`document.activeElement ? String(document.activeElement.className || document.activeElement.tagName) : null`)
|
|
234
|
+
check(String(focusedAfterClick).includes('wisp-body'),
|
|
235
|
+
'clicking her still hands her the keyboard focus (so Enter still opens the menu)',
|
|
236
|
+
String(focusedAfterClick))
|
|
237
|
+
await evaluate(`window.__wisp.move(${wasAt.x}, ${wasAt.y})`)
|
|
238
|
+
|
|
239
|
+
await sleep(1200)
|
|
240
|
+
|
|
241
|
+
head('baseline')
|
|
242
|
+
const base = await evaluate(`(() => ({
|
|
243
|
+
bodyAnim: getComputedStyle(document.querySelector('.wisp-body')).animationName,
|
|
244
|
+
amp: getComputedStyle(document.querySelector('.wisp-root')).getPropertyValue('--wisp-amp').trim(),
|
|
245
|
+
tilt: document.querySelector('.wisp-root').style.getPropertyValue('--wisp-tilt'),
|
|
246
|
+
}))()`)
|
|
247
|
+
check(base.bodyAnim === 'wisp-bob', 'she is breathing (wisp-bob really applies)', base.bodyAnim)
|
|
248
|
+
check(base.amp === '1' && base.tilt === '0deg',
|
|
249
|
+
'the amplitude variable and the initial posture resolve', `--wisp-amp=${base.amp} --wisp-tilt=${base.tilt}`)
|
|
250
|
+
|
|
251
|
+
/* 插进两层空盒子之后,精灵图**必须还铺满原来的盒子**。这一条是假 DOM 永远
|
|
252
|
+
验不了的:替身没有布局,两层 wrapper 少一个 inset:0,在那里也照样"通过",
|
|
253
|
+
而真页面上她会缩成半张图或者偏到底部。 */
|
|
254
|
+
const geom = await evaluate(`(() => {
|
|
255
|
+
const body = document.querySelector('.wisp-body').getBoundingClientRect()
|
|
256
|
+
const img = document.querySelector('.wisp-img').getBoundingClientRect()
|
|
257
|
+
const lean = document.querySelector('.wisp-lean').getBoundingClientRect()
|
|
258
|
+
return { dx: Math.abs(img.left - body.left), dy: Math.abs(img.top - body.top), dw: Math.abs(img.width - body.width), dh: Math.abs(img.height - body.height), leanW: lean.width, bodyW: body.width }
|
|
259
|
+
})()`)
|
|
260
|
+
check(geom.dx < 1 && geom.dy < 1 && geom.dw < 1 && geom.dh < 1,
|
|
261
|
+
'the sprite still fills her box exactly after the two layers went in (a missing inset:0 would shrink her)',
|
|
262
|
+
`offset ${geom.dx.toFixed(2)},${geom.dy.toFixed(2)} size delta ${geom.dw.toFixed(2)}x${geom.dh.toFixed(2)}`)
|
|
263
|
+
|
|
264
|
+
head('the pointer lean, in the real cascade')
|
|
265
|
+
const near = await evaluate(`(() => {
|
|
266
|
+
const p = window.__wisp.position
|
|
267
|
+
window.dispatchEvent(new PointerEvent('pointermove', { clientX: p.x + 280 + 60, clientY: p.y + 420, bubbles: true }))
|
|
268
|
+
return document.querySelector('.wisp-root').style.getPropertyValue('--wisp-tilt')
|
|
269
|
+
})()`)
|
|
270
|
+
await sleep(750) // 姿势有 0.5s 过渡,读太早只会读到中途
|
|
271
|
+
const leanState = await evaluate(`(() => {
|
|
272
|
+
const m = /matrix(?:3d)?\\(([^)]+)\\)/.exec(getComputedStyle(document.querySelector('.wisp-lean')).transform)
|
|
273
|
+
return { shear: m ? Math.abs(Number(m[1].split(',')[1])) : -1, raw: getComputedStyle(document.querySelector('.wisp-lean')).transform }
|
|
274
|
+
})()`)
|
|
275
|
+
check(near !== '0deg', 'a nearby pointer writes a posture angle', near)
|
|
276
|
+
check(leanState.shear > 0.005,
|
|
277
|
+
'and the engine really rotates the layer — rotate(var(--wisp-tilt)) resolves',
|
|
278
|
+
`sin(theta)=${leanState.shear.toFixed(4)} (${leanState.raw})`)
|
|
279
|
+
|
|
280
|
+
head('the press accent')
|
|
281
|
+
const pressRun = await evaluate(`(async () => {
|
|
282
|
+
const p = window.__wisp.position
|
|
283
|
+
const body = document.querySelector('.wisp-body')
|
|
284
|
+
const motion = document.querySelector('.wisp-motion')
|
|
285
|
+
const ev = new PointerEvent('pointerdown', { button: 0, clientX: p.x + 280, clientY: p.y + 420, bubbles: true, cancelable: true })
|
|
286
|
+
body.dispatchEvent(ev)
|
|
287
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
288
|
+
const anim = motion.getAnimations().find((a) => a.animationName === 'wisp-press')
|
|
289
|
+
const keyframes = anim ? anim.effect.getKeyframes().map((k) => String(k.transform)) : []
|
|
290
|
+
const out = { grabbed: ev.defaultPrevented, accent: document.querySelector('.wisp-root').dataset.accent, keyframes }
|
|
291
|
+
window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
|
292
|
+
return out
|
|
293
|
+
})()`)
|
|
294
|
+
check(pressRun.grabbed === true && pressRun.accent === 'press',
|
|
295
|
+
'a real press grabs her and sets the accent', `accent=${pressRun.accent}`)
|
|
296
|
+
const moved = pressRun.keyframes.filter((t) => t && t !== 'none' && !/matrix\\(1, 0, 0, 1, 0, 0\\)/.test(t))
|
|
297
|
+
check(pressRun.keyframes.length >= 3 && moved.length >= 1,
|
|
298
|
+
'the calc()/var() keyframes survive parsing and really move her — a dropped keyframe would run but animate nothing',
|
|
299
|
+
pressRun.keyframes.join(' | ') || '(none)')
|
|
300
|
+
|
|
301
|
+
head('the landing after a drag')
|
|
302
|
+
const landRun = await evaluate(`(async () => {
|
|
303
|
+
const p = window.__wisp.position
|
|
304
|
+
const body = document.querySelector('.wisp-body')
|
|
305
|
+
const motion = document.querySelector('.wisp-motion')
|
|
306
|
+
body.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientX: p.x + 280, clientY: p.y + 420, bubbles: true, cancelable: true }))
|
|
307
|
+
window.dispatchEvent(new PointerEvent('pointermove', { clientX: p.x + 280 + 40, clientY: p.y + 420, bubbles: true }))
|
|
308
|
+
const midTilt = document.querySelector('.wisp-root').style.getPropertyValue('--wisp-tilt')
|
|
309
|
+
window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
|
310
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
311
|
+
return {
|
|
312
|
+
midTilt,
|
|
313
|
+
accent: document.querySelector('.wisp-root').dataset.accent,
|
|
314
|
+
names: motion.getAnimations().map((a) => a.animationName),
|
|
315
|
+
afterTilt: document.querySelector('.wisp-root').style.getPropertyValue('--wisp-tilt'),
|
|
316
|
+
}
|
|
317
|
+
})()`)
|
|
318
|
+
check(Number.parseFloat(landRun.midTilt) < 0, 'dragging to the right leans her back (negative screen tilt)',
|
|
319
|
+
landRun.midTilt)
|
|
320
|
+
check(landRun.accent === 'land' && landRun.names.includes('wisp-land'),
|
|
321
|
+
'releasing after a drag lands her', `${landRun.accent} / ${landRun.names.join(', ') || '(none)'}`)
|
|
322
|
+
check(landRun.afterTilt === '0deg', 'and the posture returns to upright', landRun.afterTilt)
|
|
323
|
+
|
|
324
|
+
head('the mood pop (a gesture pops on every poke; a steady state never does)')
|
|
325
|
+
const popRun = await evaluate(`(async () => {
|
|
326
|
+
const motion = document.querySelector('.wisp-motion')
|
|
327
|
+
const root = () => document.querySelector('.wisp-root')
|
|
328
|
+
const body = document.querySelector('.wisp-body')
|
|
329
|
+
const wait = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
330
|
+
const names = () => motion.getAnimations().map((a) => a.animationName)
|
|
331
|
+
/* 先让上一步可能挂着的 flashHappy 计时器烧掉:它到点会把 mood 打回 idle,
|
|
332
|
+
而那会让"同一个表情"那一步其实又变了一次 —— 这个假警报真出现过。 */
|
|
333
|
+
await new Promise((r) => setTimeout(r, 1600))
|
|
334
|
+
window.__wisp.mood('idle')
|
|
335
|
+
await wait()
|
|
336
|
+
window.__wisp.mood('happy')
|
|
337
|
+
await wait()
|
|
338
|
+
const changed = { accent: root().dataset.accent, names: names(), mood: window.__wisp.currentMood }
|
|
339
|
+
/* 同一个表情再来一次 = 稳态重算(attn 每 1.2 秒走的就是这条路):
|
|
340
|
+
等上一次弹收尾之后必须**什么都不放**。 */
|
|
341
|
+
await new Promise((r) => setTimeout(r, 600))
|
|
342
|
+
window.__wisp.mood('happy')
|
|
343
|
+
await wait()
|
|
344
|
+
const steady = { accent: root().dataset.accent ?? null, names: names(), mood: window.__wisp.currentMood }
|
|
345
|
+
/* 手势路径:戳两下,两下都要弹,而且名字要交替(同一个值不重播)。 */
|
|
346
|
+
const p = window.__wisp.position
|
|
347
|
+
const tap = async () => {
|
|
348
|
+
body.dispatchEvent(new PointerEvent('pointerdown', {
|
|
349
|
+
button: 0, clientX: p.x + 280, clientY: p.y + 420, bubbles: true, cancelable: true,
|
|
350
|
+
}))
|
|
351
|
+
window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
|
352
|
+
await wait()
|
|
353
|
+
return { accent: root().dataset.accent, names: names() }
|
|
354
|
+
}
|
|
355
|
+
await new Promise((r) => setTimeout(r, 600))
|
|
356
|
+
const tap1 = await tap()
|
|
357
|
+
await new Promise((r) => setTimeout(r, 600))
|
|
358
|
+
const tap2 = await tap()
|
|
359
|
+
return { changed, steady, tap1, tap2 }
|
|
360
|
+
})()`)
|
|
361
|
+
const popOk = (r) => /^pop-/.test(String(r.accent)) && r.names.includes('wisp-' + r.accent)
|
|
362
|
+
check(popOk(popRun.changed), 'a mood change pops instead of only cross-fading',
|
|
363
|
+
`${popRun.changed.accent}:${popRun.changed.names.join(',')}`)
|
|
364
|
+
check(popRun.steady.mood === 'happy' && popRun.steady.accent === null
|
|
365
|
+
&& !popRun.steady.names.some((n) => String(n).startsWith('wisp-pop')),
|
|
366
|
+
'but re-asserting the same mood stays silent — that is the path the attention poll takes every 1.2s',
|
|
367
|
+
`mood=${popRun.steady.mood} accent=${popRun.steady.accent} animations=${popRun.steady.names.join(',') || '(none)'}`)
|
|
368
|
+
check(popOk(popRun.tap1) && popOk(popRun.tap2) && popRun.tap1.accent !== popRun.tap2.accent,
|
|
369
|
+
'two taps in a row both pop, with alternating names',
|
|
370
|
+
`${popRun.tap1.accent} -> ${popRun.tap2.accent}`)
|
|
371
|
+
|
|
372
|
+
head('the "still" level')
|
|
373
|
+
const stillState = await evaluate(`(() => {
|
|
374
|
+
window.__wisp.configure({ motion: 'off' })
|
|
375
|
+
const root = document.querySelector('.wisp-root')
|
|
376
|
+
const p = window.__wisp.position
|
|
377
|
+
window.dispatchEvent(new PointerEvent('pointermove', { clientX: p.x + 280 + 60, clientY: p.y + 420, bubbles: true }))
|
|
378
|
+
return {
|
|
379
|
+
attr: root.dataset.motion,
|
|
380
|
+
amp: getComputedStyle(root).getPropertyValue('--wisp-amp').trim(),
|
|
381
|
+
bodyAnim: getComputedStyle(document.querySelector('.wisp-body')).animationName,
|
|
382
|
+
tilt: root.style.getPropertyValue('--wisp-tilt'),
|
|
383
|
+
}
|
|
384
|
+
})()`)
|
|
385
|
+
check(stillState.attr === 'off' && stillState.amp === '0' && stillState.bodyAnim === 'none',
|
|
386
|
+
'"still" stops the breathing as well — she is a static sticker, not a slow one', JSON.stringify(stillState))
|
|
387
|
+
check(stillState.tilt === '0deg', 'and the posture is pinned upright', stillState.tilt)
|
|
388
|
+
|
|
389
|
+
head('system reduced motion (Emulation.setEmulatedMedia)')
|
|
390
|
+
await evaluate(`window.__wisp.configure({ motion: 'full' })`)
|
|
391
|
+
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] })
|
|
392
|
+
await sleep(800) // 等上一步可能还挂在属性上的动作收尾,否则会把残留当成新动作
|
|
393
|
+
const rm = await evaluate(`(() => {
|
|
394
|
+
const root = document.querySelector('.wisp-root')
|
|
395
|
+
const p = window.__wisp.position
|
|
396
|
+
window.dispatchEvent(new PointerEvent('pointermove', { clientX: p.x + 280 + 60, clientY: p.y + 420, bubbles: true }))
|
|
397
|
+
document.querySelector('.wisp-body').dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientX: p.x + 280, clientY: p.y + 420, bubbles: true, cancelable: true }))
|
|
398
|
+
const out = {
|
|
399
|
+
reduced: window.__wisp.doctor().motion.reduced,
|
|
400
|
+
tilt: root.style.getPropertyValue('--wisp-tilt'),
|
|
401
|
+
accent: root.dataset.accent ?? null,
|
|
402
|
+
bodyAnim: getComputedStyle(document.querySelector('.wisp-body')).animationName,
|
|
403
|
+
motionAnim: getComputedStyle(document.querySelector('.wisp-motion')).animationName,
|
|
404
|
+
}
|
|
405
|
+
window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
|
406
|
+
return out
|
|
407
|
+
})()`)
|
|
408
|
+
check(rm.reduced === true, 'the plugin reads the system setting through matchMedia', String(rm.reduced))
|
|
409
|
+
check(rm.tilt === '0deg' && rm.accent === null,
|
|
410
|
+
'no posture and no accent are written — the media query cannot reach a JS-written variable, so this guard is load-bearing',
|
|
411
|
+
`--wisp-tilt=${rm.tilt}, accent=${rm.accent}`)
|
|
412
|
+
check(rm.bodyAnim === 'none' && rm.motionAnim === 'none',
|
|
413
|
+
'and the media query stops every animation', `${rm.bodyAnim} / ${rm.motionAnim}`)
|
|
414
|
+
|
|
415
|
+
console.log(`\n=== ${failures === 0 ? 'ENGINE PROBE PASSED' : `${failures} PROBE CHECK(S) FAILED`} ===`)
|
|
416
|
+
} finally {
|
|
417
|
+
try { ws.close() } catch (e) { /* ignore */ }
|
|
418
|
+
await cleanup()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
process.exit(failures === 0 ? 0 : 1)
|