dsh-wisp 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,872 @@
1
+ /* ============================================================================
2
+ verify-wisp.mjs — pre-flight checks for the dsh-wisp bundle.
3
+
4
+ TWO FILES, TWO DIFFERENT CONTRACTS. Getting this wrong caused every load
5
+ failure this plugin had, so the checks are organised around it.
6
+
7
+ lib/index.js the HOST half — a plain ES module the host imports.
8
+ The loader entry in cordis.patch.yml points at the PACKAGE
9
+ (`dsh-wisp`), which resolves here.
10
+
11
+ lib/client.js the BROWSER half — a CLASSIC script that registers with the
12
+ web shell: window.__ModuleLoader__.load({ id, factory })
13
+ Reached through exports["./client"], served by /plugins.
14
+
15
+ The browser half is not just syntax-checked: it is EXECUTED against a
16
+ harness that reproduces the surfaces it really meets —
17
+
18
+ * all six globals the dynamic runner traps (setTimeout, setInterval,
19
+ clearTimeout, clearInterval, fetch, require) are shadowed by THROWING
20
+ functions, so touching one fails the run instead of the user's session;
21
+ * a virtual clock drives BOTH scheduling paths (the Client `timer` service
22
+ and the requestAnimationFrame fallback), so timer behaviour is asserted
23
+ rather than assumed;
24
+ * a fake DOM, Blob/URL pipeline, localStorage (including one that throws,
25
+ like an opaque origin), and a scriptable busy/composer state.
26
+
27
+ RUN
28
+ node verify-wisp.mjs
29
+ EXIT CODE 0 = both halves match their contract.
30
+ ========================================================================== */
31
+
32
+ import { readFileSync, statSync, existsSync } from 'node:fs'
33
+ import { fileURLToPath, pathToFileURL } from 'node:url'
34
+ import { dirname, join, resolve } from 'node:path'
35
+
36
+ const here = dirname(fileURLToPath(import.meta.url))
37
+ const pkgPath = join(here, 'package.json')
38
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
39
+
40
+ const hostPath = resolve(here, pkg.main ?? 'lib/index.js')
41
+ const clientRel = pkg.exports?.['./client']?.default
42
+ const clientPath = clientRel ? resolve(here, clientRel) : null
43
+
44
+ let failures = 0
45
+ const ok = (l, d = '') => console.log(` PASS ${l}${d ? ' — ' + d : ''}`)
46
+ const bad = (l, d = '') => { failures++; console.log(` FAIL ${l}${d ? ' — ' + d : ''}`) }
47
+ const head = (t) => console.log(`\n=== ${t} ===`)
48
+ const check = (cond, l, d = '') => (cond ? ok(l, d) : bad(l, d))
49
+
50
+ /* ============================================================ 0. the traps == */
51
+
52
+ /**
53
+ * The six globals a dynamic client half must never touch. The runner shadows
54
+ * them with throwing parameters; this suite reproduces that exactly, so a
55
+ * regression here fails the build instead of crashing a live page.
56
+ */
57
+ const TRAPPED = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'fetch', 'require']
58
+
59
+ /** The client half's reaction poll period; kept in step with lib/client.template.js. */
60
+ const POLL_MS = 1200
61
+
62
+ /* ==================================================== the browser harness === */
63
+
64
+ let active = null
65
+ let urlSeq = 0
66
+
67
+ class FakeBlob {
68
+ constructor(parts, opts) {
69
+ this.parts = parts
70
+ this.type = (opts && opts.type) || ''
71
+ this.size = parts[0] ? parts[0].length : 0
72
+ if (active) active.blobs.push(this)
73
+ }
74
+ }
75
+ const FakeURL = {
76
+ createObjectURL() { const url = `blob:dsh-app/verify-${++urlSeq}`; if (active) active.urLs.push(url); return url },
77
+ revokeObjectURL(url) { if (active) active.revoked.push(url) },
78
+ }
79
+ /* The constructor writes the backing field directly: routing its own
80
+ initialisation through the instrumented setter would record a phantom empty
81
+ src for every `new Image()`. */
82
+ const FakeImage = function FakeImage() { this._src = '' }
83
+ Object.defineProperty(FakeImage.prototype, 'src', {
84
+ get() { return this._src ?? '' },
85
+ set(value) {
86
+ this._src = value
87
+ if (active) active.srcs.push(value)
88
+ // The plugin builds its alpha hit-mask from an Image load, so the harness
89
+ // fires onload synchronously rather than leaving the mask path untested.
90
+ if (typeof this.onload === 'function') this.onload()
91
+ },
92
+ })
93
+ const fakeAtob = (s) => Buffer.from(s, 'base64').toString('binary')
94
+
95
+ /**
96
+ * Build one isolated page.
97
+ * @param {object} [options] - timer (service present), busy, composerText,
98
+ * storage ('memory' | 'throwing' | 'absent'), theme, viewport.
99
+ * @returns the harness: element factory, dispatch helpers, clock driver, ctx.
100
+ */
101
+ function createHarness(options = {}) {
102
+ const state = {
103
+ now: 0,
104
+ busy: !!options.busy,
105
+ composerText: options.composerText === undefined ? null : options.composerText,
106
+ }
107
+
108
+ const created = []
109
+ const frames = []
110
+ const rafSlots = new Map()
111
+ let rafSeq = 0
112
+ const timers = []
113
+ const store = new Map()
114
+
115
+ const makeEl = (tag) => {
116
+ const el = {
117
+ tagName: String(tag).toUpperCase(),
118
+ children: [], parentNode: null, dataset: {}, attrs: {},
119
+ style: { setProperty(k, v) { this[k] = v }, getPropertyValue(k) { return this[k] ?? '' } },
120
+ className: '', textContent: '', id: '', value: '',
121
+ listeners: Object.create(null),
122
+ _src: '',
123
+ get firstChild() { return this.children[0] ?? null },
124
+ get src() { return this._src },
125
+ set src(v) { this._src = v; if (this.tagName === 'IMG') harness.srcs.push(v) },
126
+ setAttribute(k, v) { this.attrs[k] = String(v) },
127
+ getAttribute(k) { return Object.hasOwn(this.attrs, k) ? this.attrs[k] : null },
128
+ appendChild(c) { if (c.parentNode) c.parentNode.removeChild(c); c.parentNode = this; this.children.push(c); return c },
129
+ append(...cs) { for (const c of cs) this.appendChild(c) },
130
+ insertBefore(c, before) {
131
+ if (c.parentNode) c.parentNode.removeChild(c)
132
+ c.parentNode = this
133
+ const at = before ? this.children.indexOf(before) : -1
134
+ if (at < 0) this.children.unshift(c)
135
+ else this.children.splice(at, 0, c)
136
+ return c
137
+ },
138
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parentNode = null; return c },
139
+ remove() { if (this.parentNode) this.parentNode.removeChild(this) },
140
+ descendants() { return this.children.flatMap((c) => [c, ...c.descendants()]) },
141
+ querySelector(sel) {
142
+ const cls = typeof sel === 'string' && sel.startsWith('.') ? sel.slice(1) : null
143
+ return cls === null ? null : this.descendants().find((d) => d.className === cls) ?? null
144
+ },
145
+ querySelectorAll(sel) {
146
+ const cls = typeof sel === 'string' && sel.startsWith('.') ? sel.slice(1) : null
147
+ return cls === null ? [] : this.descendants().filter((d) => d.className === cls)
148
+ },
149
+ addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn) },
150
+ removeEventListener(type, fn) {
151
+ const list = this.listeners[type]
152
+ if (!list) return
153
+ const at = list.indexOf(fn)
154
+ if (at >= 0) list.splice(at, 1)
155
+ },
156
+ dispatch(type, event) { for (const fn of [...(this.listeners[type] ?? [])]) fn(event ?? {}) },
157
+ listenerCount(type) { return (this.listeners[type] ?? []).length },
158
+ }
159
+ if (String(tag).toLowerCase() === 'canvas') {
160
+ // A deterministic stand-in for the sprite's alpha channel: opaque across
161
+ // the middle half of the width, transparent outside it. That is what the
162
+ // shipped sprites look like (55-87% of the width is figure, the rest is
163
+ // margin) and it makes the hit test assertable.
164
+ el.getContext = () => ({
165
+ drawImage() {},
166
+ getImageData(x, y, w, h) {
167
+ const data = new Uint8ClampedArray(w * h * 4)
168
+ for (let py = 0; py < h; py++) {
169
+ for (let px = 0; px < w; px++) {
170
+ const opaque = px >= w * 0.25 && px < w * 0.75
171
+ data[(py * w + px) * 4 + 3] = opaque ? 255 : 0
172
+ }
173
+ }
174
+ return { data }
175
+ },
176
+ })
177
+ }
178
+ created.push(el)
179
+ return el
180
+ }
181
+
182
+ const bodyEl = makeEl('body')
183
+ const headEl = makeEl('head')
184
+ /* The composer is a live node whose text follows `state.composerText`, and it
185
+ is absent (null) while that is null — so the plugin's caching AND its
186
+ re-resolution are both exercised for real. */
187
+ const composerNode = {
188
+ tagName: 'DIV',
189
+ isConnected: true,
190
+ get textContent() { return state.composerText ?? '' },
191
+ }
192
+ const queries = { stop: 0, composer: 0, all: 0 }
193
+ const COMPOSER_HOOKS = ['[data-composer-input]', 'textarea', '[contenteditable="true"]']
194
+ const documentShim = {
195
+ body: bodyEl,
196
+ head: headEl,
197
+ createElement: (t) => makeEl(t),
198
+ getElementById: (id) => created.find((e) => e.id === id) ?? null,
199
+ querySelector: (sel) => {
200
+ if (typeof sel !== 'string') return null
201
+ if (sel.startsWith('button[')) { queries.stop++; return state.busy ? {} : null }
202
+ if (COMPOSER_HOOKS.includes(sel)) {
203
+ queries.composer++
204
+ return state.composerText === null ? null : composerNode
205
+ }
206
+ return null
207
+ },
208
+ querySelectorAll: (sel) => { queries.all++; return [] },
209
+ }
210
+
211
+ const winListeners = Object.create(null)
212
+ const localStorageShim = options.storage === 'absent' ? undefined : {
213
+ getItem: (k) => {
214
+ if (options.storage === 'throwing') throw new Error('storage disabled (opaque origin)')
215
+ return store.has(k) ? store.get(k) : null
216
+ },
217
+ setItem: (k, v) => {
218
+ if (options.storage === 'throwing') throw new Error('storage disabled (opaque origin)')
219
+ store.set(k, String(v))
220
+ },
221
+ removeItem: (k) => {
222
+ if (options.storage === 'throwing') throw new Error('storage disabled (opaque origin)')
223
+ store.delete(k)
224
+ },
225
+ }
226
+
227
+ const win = {
228
+ innerWidth: options.width ?? 1920,
229
+ innerHeight: options.height ?? 1200,
230
+ performance: { now: () => state.now },
231
+ requestAnimationFrame: (fn) => { const id = ++rafSeq; rafSlots.set(id, fn); frames.push({ id, fn }); return id },
232
+ cancelAnimationFrame: (id) => {
233
+ // A no-op here would let a disposed frame survive teardown and make the
234
+ // "the loop stops" assertions meaningless.
235
+ if (!rafSlots.has(id)) return
236
+ rafSlots.delete(id)
237
+ const at = frames.findIndex((rec) => rec.id === id)
238
+ if (at >= 0) frames.splice(at, 1)
239
+ },
240
+ localStorage: localStorageShim,
241
+ addEventListener(type, fn) { (winListeners[type] ??= []).push(fn) },
242
+ removeEventListener(type, fn) {
243
+ const list = winListeners[type]
244
+ if (!list) return
245
+ const at = list.indexOf(fn)
246
+ if (at >= 0) list.splice(at, 1)
247
+ },
248
+ dispatch(type, event) { for (const fn of [...(winListeners[type] ?? [])]) fn(event ?? {}) },
249
+ listenerCount: (type) => (winListeners[type] ?? []).length,
250
+ __ModuleLoader__: { load(def) { harness.loaded = def } },
251
+ }
252
+ win.window = win
253
+
254
+ /* The Client `timer` service: registrations plus their disposers. */
255
+ const timerService = options.timer === false ? undefined : {
256
+ timeout(cb, ms) { const t = { cb, due: state.now + ms, every: 0, cancelled: false }; timers.push(t); return () => { t.cancelled = true } },
257
+ interval(cb, ms) { const t = { cb, due: state.now + ms, every: ms, cancelled: false }; timers.push(t); return () => { t.cancelled = true } },
258
+ }
259
+
260
+ const themeListeners = []
261
+ const themeSnapshot = { preference: options.themeDark ? 'dark' : 'light', active: { id: 'x', colorScheme: options.themeDark ? 'dark' : 'light', tokens: {} } }
262
+ let teardown = null
263
+ const styleInserts = []
264
+
265
+ const ctx = {
266
+ get: (name) => {
267
+ if (name === 'timer') return timerService
268
+ if (name === 'styles') return { insert: (css) => { styleInserts.push(css); return () => { styleInserts.pop() } } }
269
+ if (name === 'theme') return { getTheme: () => themeSnapshot }
270
+ return undefined
271
+ },
272
+ on: (name, fn) => {
273
+ if (name !== 'theme/change') return () => {}
274
+ themeListeners.push(fn)
275
+ return () => { const at = themeListeners.indexOf(fn); if (at >= 0) themeListeners.splice(at, 1) }
276
+ },
277
+ effect: (fn) => { teardown = fn; return () => {} },
278
+ }
279
+
280
+ const harness = {
281
+ state, ctx, win, document: documentShim, bodyEl, created, frames, timers, queries,
282
+ srcs: [], urLs: [], revoked: [], blobs: [], styleInserts,
283
+ loaded: null,
284
+ timerService,
285
+ get teardown() { return teardown },
286
+ emitTheme(snapshot) { for (const fn of [...themeListeners]) fn(snapshot ?? themeSnapshot) },
287
+ themeListenerCount: () => themeListeners.length,
288
+ fireTimers() {
289
+ for (let guard = 0; guard < 2000; guard++) {
290
+ const due = timers.filter((t) => !t.cancelled && t.due <= state.now).sort((a, b) => a.due - b.due)
291
+ if (due.length === 0) return
292
+ const t = due[0]
293
+ if (t.every > 0) t.due = state.now + t.every
294
+ else { t.cancelled = true; timers.splice(timers.indexOf(t), 1) }
295
+ t.cb()
296
+ }
297
+ },
298
+ /** Advance virtual time, pumping both scheduling paths. */
299
+ advance(ms, stepMs = 25) {
300
+ let left = ms
301
+ while (left > 0) {
302
+ const delta = Math.min(stepMs, left)
303
+ state.now += delta
304
+ left -= delta
305
+ harness.fireTimers()
306
+ // Two frames per step is enough to run everything already due; a frame
307
+ // loop that re-arms itself does not need to be drained to infinity.
308
+ for (let i = 0; i < 2 && frames.length > 0; i++) {
309
+ const rec = frames.shift()
310
+ rafSlots.delete(rec.id)
311
+ rec.fn()
312
+ }
313
+ }
314
+ },
315
+ pendingTimers: () => timers.filter((t) => !t.cancelled),
316
+ find: (className) => created.find((e) => e.className === className) ?? null,
317
+ all: (className) => created.filter((e) => e.className === className),
318
+ /** Evaluate lib/client.js the way the shell does, with every trap armed. */
319
+ evaluate(src) {
320
+ const traps = {}
321
+ for (const name of TRAPPED) traps[name] = () => { throw new Error(`${name} is not available in a dynamic client half`) }
322
+ // eslint-disable-next-line no-new-func
323
+ new Function('window', 'document', ...TRAPPED, src)(
324
+ win, documentShim, ...TRAPPED.map((name) => traps[name]),
325
+ )
326
+ },
327
+ module() {
328
+ if (!harness.loaded) throw new Error('load() was never called')
329
+ return harness.loaded.factory(() => ({}))
330
+ },
331
+ }
332
+ return harness
333
+ }
334
+
335
+ /* ======================================================= 1. the HOST half == */
336
+
337
+ head('1. host half (lib/index.js) must be importable ES module')
338
+
339
+ if (!existsSync(hostPath)) {
340
+ bad('host half exists', pkg.main)
341
+ } else {
342
+ const src = readFileSync(hostPath, 'utf8')
343
+ if (/^\s*(import|export)\s/m.test(src)) ok('uses ES module syntax')
344
+ else bad('uses ES module syntax', 'the host imports this file; a classic script cannot be imported')
345
+
346
+ try {
347
+ const mod = await import(pathToFileURL(hostPath).href + '?probe=host')
348
+ ok('host module imports cleanly')
349
+ if (typeof mod.apply === 'function') ok('exports apply()')
350
+ else bad('exports apply()')
351
+ } catch (error) {
352
+ bad('host module imports cleanly', `${error.constructor.name}: ${error.message}`)
353
+ }
354
+ }
355
+
356
+ /* ===================================================== 2. the BROWSER half == */
357
+
358
+ head('2. browser half (lib/client.js) must register with the module loader')
359
+
360
+ const clientSrc = clientPath && existsSync(clientPath) ? readFileSync(clientPath, 'utf8') : null
361
+
362
+ if (clientSrc === null) {
363
+ bad('exports["./client"] resolves', String(clientRel))
364
+ } else {
365
+ if (/^\s*(import|export)\s/m.test(clientSrc)) {
366
+ bad('no import/export statements', 'this is a classic script, not a module')
367
+ } else ok('no import/export statements')
368
+
369
+ if (/window\.__ModuleLoader__\.load\(\s*\{/.test(clientSrc)) ok('calls window.__ModuleLoader__.load({…})')
370
+ else bad('calls window.__ModuleLoader__.load({…})', 'this is how a browser half registers')
371
+
372
+ if (/id:\s*['"]dsh-wisp\/client['"]/.test(clientSrc)) ok("registers under id 'dsh-wisp/client'")
373
+ else bad("registers under id 'dsh-wisp/client'")
374
+
375
+ if (/factory:\s*\(require\)\s*=>/.test(clientSrc)) ok('factory takes `require`')
376
+ else bad('factory takes `require`')
377
+
378
+ if (/return\s+module\.exports/.test(clientSrc)) ok('factory returns module.exports')
379
+ else bad('factory returns module.exports')
380
+
381
+ if (/<\/script/i.test(clientSrc)) bad('no literal </script>', 'would break inline embedding')
382
+ else ok('no literal </script>')
383
+
384
+ // Static floor under the trap test below: a call to a trapped global is a
385
+ // guaranteed runtime throw in a client half, and no bundler flags it.
386
+ const trappedCall = clientSrc.match(/(setTimeout|setInterval|clearTimeout|clearInterval|fetch|require)\s*\(/)
387
+ if (trappedCall) bad('never calls a trapped global', `found ${trappedCall[1]}(`)
388
+ else ok('never calls a trapped global', 'checked statically; execution below proves it')
389
+ }
390
+
391
+ /* ============================ 3. the browser half, executed and driven ===== */
392
+
393
+ head('3. browser half runs against the real contract (traps armed)')
394
+
395
+ const installFakes = () => {
396
+ globalThis.URL = FakeURL
397
+ globalThis.Blob = FakeBlob
398
+ globalThis.atob = fakeAtob
399
+ globalThis.Image = FakeImage
400
+ }
401
+
402
+ if (clientSrc !== null) {
403
+ const savedGlobals = { URL: globalThis.URL, Blob: globalThis.Blob, atob: globalThis.atob, Image: globalThis.Image }
404
+ installFakes()
405
+
406
+ try {
407
+ /* ---------------------------------------------------------- 3a. mount -- */
408
+ head('3a. Client timer service path')
409
+
410
+ const h = createHarness({ timer: true })
411
+ active = h
412
+ h.evaluate(clientSrc)
413
+ ok('registers without throwing', `id=${h.loaded.id}`)
414
+
415
+ const exportsObj = h.module()
416
+ const plugin = exportsObj?.default
417
+ check(typeof plugin?.apply === 'function', 'exports a mountable plugin', `name=${plugin?.name}`)
418
+
419
+ plugin.apply(h.ctx, { happyMs: 6000 })
420
+ const api = h.win.__wisp
421
+ check(typeof api?.destroy === 'function', 'public handle exposed', Object.keys(api ?? {}).join(','))
422
+ check(api?.clock === 'timer-service', 'schedules on the Client timer service', String(api?.clock))
423
+
424
+ h.advance(50)
425
+ const layer = h.find('wisp-layer')
426
+ const root = h.find('wisp-root')
427
+ check(layer !== null && layer.parentNode === h.bodyEl, 'mounted into document.body')
428
+ check(root !== null, 'creature element exists', `mood=${root?.dataset.mood}`)
429
+ check(h.styleInserts.length === 1, 'stylesheet pushed through the styles builtin')
430
+ check(h.frames.length === 0, 'spends zero animation frames while scheduling', 'the rAF loop is not even armed')
431
+ check(root.style.width === '560px' && root.style.height === '840px',
432
+ 'drawn at the enlarged default (140x210 base x 4)', `${root.style.width} x ${root.style.height}`)
433
+
434
+ /* ------------------------------------------------- 3b. sprite pipeline -- */
435
+ head('3b. sprite pipeline (the broken-image regression)')
436
+
437
+ if (h.srcs.length === 0) {
438
+ bad('sprite assigned to an <img>', 'no src was ever set')
439
+ } else if (h.srcs.every((s) => typeof s === 'string' && s.startsWith('blob:'))) {
440
+ ok('sprites use blob: object URLs', h.srcs[0])
441
+ } else if (h.srcs.some((s) => typeof s === 'string' && s.startsWith('data:'))) {
442
+ bad('sprites must not use data: URIs', 'this shell renders them as a broken image')
443
+ } else {
444
+ bad('sprite URL scheme', JSON.stringify(h.srcs).slice(0, 200))
445
+ }
446
+ check(h.blobs.length > 0, 'base64 decoded into Blob objects', `${h.blobs.length} blob(s)`)
447
+ check(h.blobs.every((b) => b.type.startsWith('image/')), 'blob MIME types are images', h.blobs[0]?.type)
448
+
449
+ /* ------------------------------------------------ 3c. moods and speech -- */
450
+ head('3c. moods, speech and idle/sleep')
451
+
452
+ h.advance(900)
453
+ const layerEl = api.element.parentNode
454
+ check(layerEl.querySelectorAll('.wisp-say').length === 1, 'says hello after mount')
455
+ api.say('测试')
456
+ check(layerEl.querySelectorAll('.wisp-say').length === 1, 'say() replaces the bubble rather than stacking',
457
+ `${layerEl.querySelectorAll('.wisp-say').length} attached`)
458
+ check(api.element.querySelectorAll('.wisp-say').length === 0,
459
+ 'the bubble is not inside the mirrored root', 'a child of the root would render its text backwards')
460
+ api.mood('happy')
461
+ check(root.dataset.mood === 'happy', 'mood() control works')
462
+ check(h.srcs[h.srcs.length - 1] !== h.srcs[0], 'sprite switches with mood', String(h.srcs[h.srcs.length - 1]))
463
+
464
+ // The swap must be a cross-fade: two layers for the length of the fade,
465
+ // then the outgoing one is dropped. A hard `src` swap reads as a glitch.
466
+ const bodyNode = root.querySelector('.wisp-body')
467
+ const imgLayers = () => bodyNode.querySelectorAll('.wisp-img').length
468
+ check(imgLayers() === 2, 'a mood change cross-fades instead of swapping the src', `${imgLayers()} layers`)
469
+ h.advance(400)
470
+ check(imgLayers() === 1, 'the outgoing sprite layer is dropped after the fade', `${imgLayers()} layers`)
471
+
472
+ api.mood('idle')
473
+ h.advance(91000, 500)
474
+ check(root.dataset.mood === 'sleep', 'sleeps after the idle window', String(root.dataset.mood))
475
+ const zzz = root.querySelectorAll('.wisp-zzz').length
476
+ check(zzz === 2, 'shows two z glyphs while asleep', `${zzz}`)
477
+ h.win.dispatch('pointermove', {})
478
+ h.advance(10)
479
+ check(root.dataset.mood === 'idle', 'any input wakes her')
480
+ check(root.querySelectorAll('.wisp-zzz').length === 0, 'the z glyphs go when she wakes')
481
+
482
+ /* --------------------------------------------------- 3d. reactions ----- */
483
+ head('3d. reactions: busy, composer, completion')
484
+
485
+ // Each phase advances just past one poll so the assertion lands inside the
486
+ // state it is testing instead of one poll later.
487
+ h.state.busy = true
488
+ h.advance(1300, 100)
489
+ check(root.dataset.mood === 'alert', 'agent busy -> alert', String(root.dataset.mood))
490
+ h.advance(4000, 500)
491
+ h.state.busy = false
492
+ h.advance(1300, 100)
493
+ check(root.dataset.mood === 'happy', 'a finished run is celebrated', String(root.dataset.mood))
494
+ const said = layerEl.querySelector('.wisp-say')
495
+ check(said !== null && ['跑完了。', '收了。', '这一轮结束。', '去看看?'].includes(said.textContent),
496
+ 'the celebration says something', said?.textContent)
497
+ h.advance(7000, 500)
498
+ check(root.dataset.mood === 'idle', 'the celebration ends on its own', String(root.dataset.mood))
499
+
500
+ h.state.composerText = 'hello'
501
+ h.advance(1300, 100)
502
+ check(root.dataset.mood === 'alert', 'composer text -> alert', String(root.dataset.mood))
503
+ h.state.composerText = null
504
+ h.advance(1300, 100)
505
+ check(root.dataset.mood === 'idle', 'back to idle when nothing is happening', String(root.dataset.mood))
506
+
507
+ /* ------------------------------------------- 3d-bis. what a tick costs -- */
508
+ head('3d-bis. one tick does not re-walk the document')
509
+
510
+ // A composer that is present and unchanged must be resolved once and then
511
+ // read directly; a tick that re-queries it is the difference between
512
+ // ~0.3 ms and ~0 ms per tick on a long transcript.
513
+ const beforeQueries = { ...h.queries }
514
+ h.state.composerText = 'still typing'
515
+ h.advance(POLL_MS * 8, 100)
516
+ const composerSpend = h.queries.composer - beforeQueries.composer
517
+ check(composerSpend === 0, 'a resolved composer is not re-queried per tick', `${composerSpend} query/queries over 8 ticks`)
518
+ check(h.queries.all === beforeQueries.all, 'the tick never collects a NodeList', `${h.queries.all - beforeQueries.all}`)
519
+ h.state.composerText = null
520
+ h.advance(1300, 100)
521
+
522
+ // With no composer open at all (the blank-session Hero), the precise hook is
523
+ // probed — cheaply, and so a session that opens is noticed at once — while
524
+ // the ambiguous legacy shapes are only probed on the retry cadence.
525
+ const hm = createHarness({ timer: true, composerText: null })
526
+ active = hm
527
+ hm.evaluate(clientSrc)
528
+ hm.module().apply(hm.ctx, {})
529
+ const TICKS = 9
530
+ hm.advance(POLL_MS * TICKS, 100)
531
+ const probes = hm.queries.composer
532
+ check(probes <= TICKS + Math.ceil(TICKS / 4) * 2,
533
+ 'an absent composer costs one probe per tick, not three',
534
+ `${probes} probes over ${TICKS} ticks (the old shape paid ${TICKS * 3})`)
535
+ hm.win.__wisp.destroy()
536
+ active = h // hand the Blob/URL recorder back to the main harness
537
+
538
+ /* ------------------------------------------------------ 3e. theme ------ */
539
+ head('3e. theme follows the shell')
540
+
541
+ check(h.themeListenerCount() === 1, 'subscribed to theme/change')
542
+ h.emitTheme({ active: { colorScheme: 'dark' } })
543
+ check(root.dataset.wispTheme === 'dark', 'dark theme is reflected on the element', String(root.dataset.wispTheme))
544
+ h.emitTheme({ active: { colorScheme: 'light' } })
545
+ check(root.dataset.wispTheme === 'light', 'light theme is reflected on the element', String(root.dataset.wispTheme))
546
+
547
+ /* ------------------------------------------------- 3f. drag + persistence */
548
+ head('3f. drag, and the position it remembers')
549
+
550
+ const bodyWisp = h.find('wisp-body')
551
+ const SCALE = 4
552
+ const BOX_W = 140 * SCALE
553
+ const BOX_H = 210 * SCALE
554
+ const centre = () => ({ x: api.position.x + BOX_W / 2, y: api.position.y + BOX_H / 2 })
555
+ const press = (x, y) => {
556
+ const ev = { button: 0, clientX: x, clientY: y, defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() {} }
557
+ bodyWisp.dispatch('pointerdown', ev)
558
+ return ev
559
+ }
560
+
561
+ const before = api.position
562
+ const start = centre()
563
+ check(press(start.x, start.y).defaultPrevented, 'a press on her body takes the pointer', `${start.x},${start.y}`)
564
+ h.win.dispatch('pointermove', { clientX: start.x - 300, clientY: start.y - 60 })
565
+ h.win.dispatch('pointerup', {})
566
+ const after = api.position
567
+ check(after.x !== before.x || after.y !== before.y, 'dragging moves her', `${before.x},${before.y} -> ${after.x},${after.y}`)
568
+ check(after.x === before.x - 300 && after.y === before.y - 60, 'she follows the pointer exactly', `${after.x},${after.y}`)
569
+
570
+ /* The transform must place the box's TOP-LEFT at the reported position. An
571
+ earlier build translated by `x + boxW/2` "for the centre origin", which a
572
+ CSS translation ignores — the box rendered half a box-width to the right,
573
+ off the viewport edge, while the hit mask kept sampling from `coords.x`.
574
+ That made her impossible to grab. Assert the rendered box, not the state. */
575
+ const boxAt = (t) => {
576
+ const m = /translate3d\(([-\d.]+)px,([-\d.]+)px,0\) scaleX\((-?\d)\)/.exec(String(t))
577
+ return m === null ? null : { x: Number(m[1]), y: Number(m[2]), facing: Number(m[3]) }
578
+ }
579
+ const placed = boxAt(root.style.transform)
580
+ check(placed !== null && Math.abs(placed.x - after.x) <= 1 && Math.abs(placed.y - after.y) <= 1,
581
+ 'the rendered box starts exactly where the position says',
582
+ `transform at ${placed?.x},${placed?.y} vs position ${after.x},${after.y}`)
583
+
584
+ const storedRaw = h.win.localStorage.getItem('dsh-wisp:position:v1')
585
+ check(typeof storedRaw === 'string' && JSON.parse(storedRaw).x === after.x, 'drag end persists the position', storedRaw)
586
+
587
+ // A plain click must not be mistaken for a drag.
588
+ const held = api.position
589
+ const c1 = centre()
590
+ press(c1.x, c1.y)
591
+ h.win.dispatch('pointerup', {})
592
+ check(api.position.x === held.x && api.position.y === held.y, 'a click does not move her')
593
+
594
+ /* ---- the transparent margin must not swallow the app's clicks ------ */
595
+ const edge = press(api.position.x + 4, api.position.y + BOX_H / 2)
596
+ check(edge.defaultPrevented === false,
597
+ 'a press on her transparent margin passes through to the app', `defaultPrevented=${edge.defaultPrevented}`)
598
+ const stillHeld = api.position
599
+ h.win.dispatch('pointermove', { clientX: 0, clientY: 0 })
600
+ h.win.dispatch('pointerup', {})
601
+ check(api.position.x === stillHeld.x && api.position.y === stillHeld.y,
602
+ 'a margin press does not start a drag', `${api.position.x},${api.position.y}`)
603
+
604
+ /* ---- she is grabbable wherever she is drawn ------------------------ */
605
+ // Deliberate product decision: she does not yield to a control underneath,
606
+ // because the user can simply move her. Pin it so a future "safety" tweak
607
+ // cannot quietly make parts of her undraggable again.
608
+ const c3 = centre()
609
+ const heldBy = api.position
610
+ const regrab = press(c3.x, c3.y)
611
+ check(regrab.defaultPrevented, 'a press on her body always grabs her, control or not')
612
+ h.win.dispatch('pointermove', { clientX: c3.x - 40, clientY: c3.y })
613
+ h.win.dispatch('pointerup', {})
614
+ check(api.position.x === heldBy.x - 40, 'and it drags her', `${api.position.x} vs ${heldBy.x - 40}`)
615
+
616
+ /* ---- geometry must follow the render scale, and the mirror must pivot -- */
617
+ const c2 = centre()
618
+ press(c2.x, c2.y)
619
+ h.win.dispatch('pointermove', { clientX: 9999, clientY: 9999 })
620
+ h.win.dispatch('pointerup', {})
621
+ const corner = api.position
622
+ check(corner.x === h.win.innerWidth - BOX_W && corner.y === h.win.innerHeight - BOX_H,
623
+ 'the clamp uses the SCALED box, so she cannot hang off the edge',
624
+ `x=${corner.x} (max ${h.win.innerWidth - BOX_W}), y=${corner.y} (max ${h.win.innerHeight - BOX_H})`)
625
+
626
+ api.move(0, 300)
627
+ const tr = boxAt(root.style.transform)
628
+ check(tr !== null, 'the transform is a plain translate plus a mirror', String(root.style.transform))
629
+ if (tr !== null) {
630
+ check(tr.facing === -1, 'she turns to face left on the left half', `scaleX(${tr.facing})`)
631
+ check(tr.x === 0 && tr.y === 300, 'turning around does not shift her box',
632
+ `translate ${tr.x},${tr.y} for position 0,300`)
633
+ }
634
+ api.move(after.x, after.y) // put her back for the teardown/persistence checks
635
+
636
+ /* ------------------------------------------------------ 3g. teardown --- */
637
+ head('3g. teardown')
638
+
639
+ const liveBefore = h.pendingTimers().length
640
+ const undestroyed = h.teardown
641
+ check(typeof undestroyed === 'function', 'ctx.effect teardown registered')
642
+ const disposal = undestroyed()
643
+ check(typeof disposal === 'function', 'ctx.effect returns a disposer')
644
+ check(liveBefore > 0, 'timers were live before teardown', `${liveBefore}`)
645
+ disposal()
646
+
647
+ check(h.pendingTimers().length === 0, 'every timer is disposed', `left ${h.pendingTimers().length}`)
648
+ check(h.win.listenerCount('pointermove') === 0, 'window listeners are removed')
649
+ check(h.win.listenerCount('keydown') === 0, 'keydown listener is removed')
650
+ check(layer.parentNode === null, 'the layer leaves the DOM')
651
+ check(h.srcs.length > 0 && h.revoked.length > 0, 'sprite blob URLs are revoked', `${h.revoked.length} revoked`)
652
+ check(h.win.__wisp === undefined, 'the public handle is withdrawn')
653
+ check(h.themeListenerCount() === 0, 'the theme subscription is released', `${h.themeListenerCount()} left`)
654
+
655
+ // destroy() twice must be a no-op, not a second teardown.
656
+ let doubleThrow = null
657
+ try { api.destroy() } catch (error) { doubleThrow = error }
658
+ check(doubleThrow === null, 'destroy() is idempotent')
659
+
660
+ /* ------------------------------- 3h. re-apply: singleton + fresh blobs -- */
661
+ head('3h. re-apply keeps exactly one companion (HMR / duplicate row)')
662
+
663
+ // Same module instance: the blob cache is module-scoped, so a stale cache
664
+ // here is exactly how a re-mount served a revoked URL and drew a broken
665
+ // image. The new instance must get URLs that are not in the revoked set.
666
+ plugin.apply(h.ctx, {})
667
+ const layers = h.all('wisp-layer')
668
+ check(layers.length === 2 && layers.filter((l) => l.parentNode === h.bodyEl).length === 1,
669
+ 'exactly one companion is mounted', `${layers.filter((l) => l.parentNode === h.bodyEl).length} attached`)
670
+ const second = h.win.__wisp
671
+ check(second !== api && typeof second?.destroy === 'function', 'the handle points at the new instance')
672
+ const freshSrcs = h.srcs.slice(-1)
673
+ check(freshSrcs[0]?.startsWith('blob:') && !h.revoked.includes(freshSrcs[0]),
674
+ 'the re-mount is not handed a revoked blob URL', String(freshSrcs[0]))
675
+ const restored = second.position
676
+ check(restored.x === after.x && restored.y === after.y, 'the remembered position is restored on the next mount',
677
+ `${restored.x},${restored.y}`)
678
+
679
+ /* ------------------------------------------------- 3i. reconfigure ------ */
680
+ head('3i. configure() merges instead of resetting')
681
+
682
+ plugin.apply(h.ctx, { reactions: false, persist: false })
683
+ const third = h.win.__wisp
684
+ // Read geometry off the LIVE handle: `h.find` returns the first element with
685
+ // that class, which belongs to an instance that was already replaced.
686
+ const liveRoot = third.element
687
+ const timersBefore = h.pendingTimers().length
688
+ third.configure({ size: 2 })
689
+ check(liveRoot.style.width === '280px' && liveRoot.style.height === '420px', 'configure() resizes her box',
690
+ `${liveRoot.style.width} x ${liveRoot.style.height}`)
691
+ check(h.pendingTimers().length <= timersBefore + 1, 'configure() did not resurrect the reaction poll',
692
+ `${timersBefore} -> ${h.pendingTimers().length}`)
693
+ third.configure({ size: 'huge', sleepAfterMs: -1 })
694
+ check(!String(liveRoot.style.width).includes('NaN') && !String(liveRoot.style.transform).includes('NaN'),
695
+ 'a nonsense config cannot produce NaN geometry', `${liveRoot.style.width} / ${liveRoot.style.transform}`)
696
+ third.destroy()
697
+
698
+ /* ------------------------------------------- 3i-bis. 自己踱步 + 右键归位 */
699
+ head('3i-bis. idle wander and the right-click trip home')
700
+
701
+ plugin.apply(h.ctx, { reactions: false, wander: true, wanderMs: 5000, wanderRange: 200 })
702
+ const fifth = h.win.__wisp
703
+ const body5 = fifth.element.querySelector('.wisp-body')
704
+ const BOX_W5 = 140 * 4
705
+ const BOX_H5 = 210 * 4
706
+ const centreOf = () => ({ x: fifth.position.x + BOX_W5 / 2, y: fifth.position.y + BOX_H5 / 2 })
707
+
708
+ fifth.move(200, 200) // away from the corners, so any bearing can move
709
+ h.advance(6000, 200)
710
+ const strolled = fifth.position
711
+ check(strolled.x !== 200 || strolled.y !== 200, 'she strolls on her own while idle',
712
+ `200,200 -> ${strolled.x},${strolled.y}`)
713
+ check(Math.abs(strolled.x - 200) <= 200 && Math.abs(strolled.y - 200) <= 200,
714
+ 'a stroll stays inside the configured range', `${strolled.x},${strolled.y}`)
715
+ check(fifth.element.dataset.gliding === 'true', 'the stroll is a glide, not a teleport')
716
+ h.advance(2000, 200)
717
+ check(fifth.element.dataset.gliding === undefined, 'the glide class is cleared afterwards')
718
+ check(JSON.parse(h.win.localStorage.getItem('dsh-wisp:position:v1') || '{}').x !== strolled.x,
719
+ 'a stroll does not overwrite the position the user chose', h.win.localStorage.getItem('dsh-wisp:position:v1'))
720
+
721
+ // 右键:任何位置都要能一下子叫回右下角
722
+ const clickPoint = centreOf()
723
+ const home = { clientX: clickPoint.x, clientY: clickPoint.y, defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() {} }
724
+ body5.dispatch('contextmenu', home)
725
+ check(home.defaultPrevented, 'a right-click on her body is handled')
726
+ const homed = fifth.position
727
+ check(homed.x === h.win.innerWidth - 26 - BOX_W5 && homed.y === h.win.innerHeight - 46 - BOX_H5,
728
+ 'the right-click puts her back in her corner', `${homed.x},${homed.y}`)
729
+ check(JSON.parse(h.win.localStorage.getItem('dsh-wisp:position:v1')).x === homed.x,
730
+ 'the remembered position follows her home')
731
+
732
+ // 但踱步不能在她被拖拽 / 睡着 / 忙碌时乱动
733
+ fifth.move(300, 300)
734
+ fifth.mood('sleep')
735
+ h.advance(6000, 200)
736
+ check(fifth.position.x === 300 && fifth.position.y === 300, 'no strolling while she is asleep',
737
+ `${fifth.position.x},${fifth.position.y}`)
738
+ fifth.destroy()
739
+
740
+ /* ------------------------------------------- 3i-ter. 关掉踱步就不动 ---- */
741
+ plugin.apply(h.ctx, { reactions: false, wander: false })
742
+ const sixth = h.win.__wisp
743
+ sixth.move(300, 300)
744
+ h.advance(3000, 200)
745
+ check(sixth.position.x === 300 && sixth.position.y === 300, 'wander: false keeps her still',
746
+ `${sixth.position.x},${sixth.position.y}`)
747
+ sixth.destroy()
748
+
749
+ /* ------------------------------ 3j. storage that throws (opaque origin) - */
750
+ head('3j. a page without usable storage still works')
751
+
752
+ for (const mode of ['throwing', 'absent']) {
753
+ const hs = createHarness({ timer: true, storage: mode })
754
+ active = hs
755
+ let error = null
756
+ try {
757
+ hs.evaluate(clientSrc)
758
+ const mod = hs.module()
759
+ mod.apply(hs.ctx, {})
760
+ hs.advance(100)
761
+ const body = hs.find('wisp-body')
762
+ body?.dispatch('pointerdown', { button: 0, clientX: 900, clientY: 700, preventDefault() {}, stopPropagation() {} })
763
+ hs.win.dispatch('pointermove', { clientX: 400, clientY: 300 })
764
+ hs.win.dispatch('pointerup', {})
765
+ const handle = hs.win.__wisp
766
+ handle.destroy()
767
+ } catch (e) { error = e }
768
+ check(error === null, `mounts and tears down with storage ${mode}`, error ? String(error.message) : '')
769
+ }
770
+
771
+ /* --------------------------------- 3k. the rAF fallback (no timer svc) -- */
772
+ head('3k. requestAnimationFrame fallback when the timer service is absent')
773
+
774
+ const hf = createHarness({ timer: false, composerText: null })
775
+ active = hf
776
+ hf.evaluate(clientSrc)
777
+ const modF = hf.module()
778
+ modF.apply(hf.ctx, { reactions: false, chatterMs: 0, sleepAfterMs: 5000, wander: false })
779
+ const apiF = hf.win.__wisp
780
+ const rootF = apiF.element
781
+ check(apiF?.clock === 'animation-frame', 'falls back to the frame clock', String(apiF?.clock))
782
+ check(hf.frames.length > 0, 'the fallback arms a frame while work is pending')
783
+ hf.advance(6000, 50)
784
+ check(rootF.dataset.mood === 'sleep', 'the fallback still sleeps on schedule', String(rootF.dataset.mood))
785
+ // Long enough that the bubble job the sleep line may have armed is gone too.
786
+ hf.advance(8000, 50)
787
+ check(hf.frames.length === 0, 'the fallback stops the frame loop once nothing is pending',
788
+ `${hf.frames.length} frame(s) armed`)
789
+ apiF.destroy()
790
+ check(hf.frames.length === 0, 'teardown leaves no armed frame', `${hf.frames.length} frame(s) armed`)
791
+ } catch (error) {
792
+ bad('browser half executes', `${error.constructor.name}: ${error.message}`)
793
+ console.log(error.stack)
794
+ } finally {
795
+ active = null
796
+ for (const [key, value] of Object.entries(savedGlobals)) globalThis[key] = value
797
+ }
798
+ }
799
+
800
+ /* ======================================================= 4. packaging ====== */
801
+
802
+ head('4. packaging consistency')
803
+
804
+ const patchPath = pkg.dsh?.bundle?.patch ? resolve(here, pkg.dsh.bundle.patch) : null
805
+ if (patchPath && existsSync(patchPath)) {
806
+ ok('dsh.bundle.patch exists', pkg.dsh.bundle.patch)
807
+ const yml = readFileSync(patchPath, 'utf8')
808
+ const nameMatch = yml.match(/^\s*name:\s*["']?([^"'\s]+)["']?\s*$/m)
809
+ const entry = nameMatch ? nameMatch[1] : null
810
+ if (entry === pkg.name) ok('patch entry points at the host half', entry)
811
+ else bad('patch entry points at the host half', `patch says '${entry}', should be '${pkg.name}' (never '<pkg>/client')`)
812
+ } else bad('dsh.bundle.patch exists')
813
+
814
+ if (!pkg.dsh?.client?.inject && !pkg.dsh?.client?.shared) ok('no inject/shared declarations')
815
+ else bad('inject/shared declared', 'a local-path install cannot satisfy them')
816
+
817
+ /* ---- 5. shipping readiness: sprites inlined, attribution present ---- */
818
+
819
+ head('5. redistribution readiness')
820
+
821
+ if (clientSrc !== null) {
822
+ const uris = (clientSrc.match(/data:image\/(?:webp|png);base64,/g) || []).length
823
+ if (uris === 4) ok('all four sprites inlined as data URIs')
824
+ else bad('all four sprites inlined as data URIs', `found ${uris}, expected 4`)
825
+
826
+ if (clientSrc.includes('__SPRITES__')) bad('no leftover build placeholder', 'run `node build.mjs`')
827
+ else ok('no leftover build placeholder')
828
+
829
+ // The bundle must be self-contained: a share carries no asset paths.
830
+ const refsAssetFile = /['"][^'"]*\.(?:webp|png)['"]/.test(clientSrc)
831
+ if (!refsAssetFile) ok('bundle references no external image files')
832
+ else bad('bundle references no external image files', 'a share would break on missing assets')
833
+
834
+ const stamped = clientSrc.match(/const VERSION = '([^']+)'/)
835
+ if (stamped && stamped[1] === pkg.version) ok('the bundle stamps the package version', stamped[1])
836
+ else bad('the bundle stamps the package version', `bundle ${stamped?.[1] ?? 'none'} vs package ${pkg.version}`)
837
+ }
838
+
839
+ if (existsSync(join(here, 'NOTICE.md'))) ok('NOTICE.md present', 'character provenance + image licence')
840
+ else bad('NOTICE.md present', 'required when redistributing the character images')
841
+
842
+ const notice = existsSync(join(here, 'NOTICE.md')) ? readFileSync(join(here, 'NOTICE.md'), 'utf8') : ''
843
+ if (/非官方|unofficial|not affiliated/i.test(notice)) ok('NOTICE states it is unofficial')
844
+ else bad('NOTICE states it is unofficial')
845
+
846
+ if (/CC BY-NC-SA/i.test(notice)) ok('NOTICE separates the image licence from MIT')
847
+ else bad('NOTICE separates the image licence from MIT')
848
+
849
+ if (pkg.private === true) bad('package is publishable', 'remove "private": true to share it')
850
+ else ok('package is publishable')
851
+
852
+ if (pkg.dsh?.client?.platform) ok('dsh.client.platform declared', pkg.dsh.client.platform)
853
+ else bad('dsh.client.platform declared')
854
+
855
+ const deps = Object.keys(pkg.dependencies ?? {}).length + Object.keys(pkg.peerDependencies ?? {}).length
856
+ if (deps === 0) ok('no dependencies at all')
857
+ else bad(`${deps} dependency declaration(s)`)
858
+
859
+ // Every path the manifest promises must exist, or an install ships a hole.
860
+ const declaredFiles = Array.isArray(pkg.files) ? pkg.files : []
861
+ const missingFiles = declaredFiles.filter((rel) => !existsSync(join(here, rel)))
862
+ if (declaredFiles.length === 0) bad('package.json files[] declares the shipped set')
863
+ else if (missingFiles.length === 0) ok('every path in files[] exists', `${declaredFiles.length} entries`)
864
+ else bad('every path in files[] exists', `missing: ${missingFiles.join(', ')}`)
865
+
866
+ /* ============================================================= done ====== */
867
+
868
+ head(failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`)
869
+ console.log(failures === 0
870
+ ? ' Both halves match their contracts. A remaining failure would be outside\n this package: restart the app to rebuild its loader graph.\n'
871
+ : ' Fix the FAIL lines above before reloading the app.\n')
872
+ process.exit(failures === 0 ? 0 : 1)