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.
- package/LICENSE +32 -0
- package/NOTICE.md +151 -0
- package/README.md +285 -0
- package/assets/happy.webp +0 -0
- package/assets/happy_hi.webp +0 -0
- package/assets/happy_md.webp +0 -0
- package/assets/happy_sm.webp +0 -0
- package/assets/idle.webp +0 -0
- package/assets/idle_hi.webp +0 -0
- package/assets/idle_md.webp +0 -0
- package/assets/idle_sm.webp +0 -0
- package/assets/sleepy.webp +0 -0
- package/assets/sleepy_hi.webp +0 -0
- package/assets/sleepy_md.webp +0 -0
- package/assets/sleepy_sm.webp +0 -0
- package/assets/work.webp +0 -0
- package/assets/work_hi.webp +0 -0
- package/assets/work_md.webp +0 -0
- package/assets/work_sm.webp +0 -0
- package/build.mjs +169 -0
- package/cordis.patch.yml +51 -0
- package/lib/client.js +1091 -0
- package/lib/client.template.js +1086 -0
- package/lib/index.js +19 -0
- package/package.json +72 -0
- package/tools/CHARACTER-PROMPT.md +88 -0
- package/tools/assets.mjs +139 -0
- package/tools/keyout.mjs +104 -0
- package/verify-wisp.mjs +872 -0
package/verify-wisp.mjs
ADDED
|
@@ -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)
|