dsh-wisp 1.42.0 → 1.44.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/verify-wisp.mjs CHANGED
@@ -504,9 +504,13 @@ function createHarness(options = {}) {
504
504
  // 默认不给 —— 这样"没有 host 时降级"才测得到;要测检查更新就传 hostCall。
505
505
  const hostSeat = typeof options.hostCall === 'function' ? { call: options.hostCall } : undefined
506
506
  // eslint-disable-next-line no-new-func
507
- /* 追加 sourceURL:new Function 里的代码否则在堆栈里是 anonymous,定位不到行。 */
508
- new Function('window', 'document', 'host', ...TRAPPED, src + '\n//# sourceURL=wisp-client-half.js')(
509
- win, documentShim, hostSeat, ...TRAPPED.map((name) => traps[name]),
507
+ /* 追加 sourceURL:new Function 里的代码否则在堆栈里是 anonymous,定位不到行。
508
+ navigator 必须显式注入:浏览器里裸 navigator === window.navigator,而 **Node 21+
509
+ 也有全局 navigator**(navigator.language = 系统语言)。不注入的话,客户端读到的是
510
+ Node 的 —— 中文机器上绿、英文 runner 上红(真踩过:Node 22 上 24 项台词池检查全红,
511
+ Node 20 上因为那个全局还不存在而"恰好"绿)。 */
512
+ new Function('window', 'document', 'host', 'navigator', ...TRAPPED, src + '\n//# sourceURL=wisp-client-half.js')(
513
+ win, documentShim, hostSeat, win.navigator ?? {}, ...TRAPPED.map((name) => traps[name]),
510
514
  )
511
515
  },
512
516
  module() {
@@ -551,7 +555,7 @@ head('1b. the host half answers checkUpdate through the platform web service')
551
555
  {
552
556
  const mod = await import(pathToFileURL(hostPath).href + '?probe=update')
553
557
  const { readPublishedVersion, compareVersions, registerHandlers, inject } = mod
554
- const { readBalance, accountClientMetadata, normalizeWallets, resolveWebService } = mod
558
+ const { readBalance, accountClientMetadata, normalizeWallets, resolveWebService, readManagedVersion } = mod
555
559
 
556
560
  /* web 服务必须声明为**可选**:硬依赖一个没加载的服务会让 fiber 永远 waiting,
557
561
  她会直接从界面上消失。这条是安全属性,不是风格偏好。 */
@@ -584,6 +588,22 @@ head('1b. the host half answers checkUpdate through the platform web service')
584
588
  const U = { npm: 'https://npm/x', github: 'https://gh/y' }
585
589
  const only = (key, outcome) => webWith({ [U[key]]: outcome })
586
590
 
591
+ /* 源可以换:国内镜像 / 私有 registry 只需要把 config.urls 指过去。
592
+ 这一条钉住"换源真的会去问新地址"—— 否则"支持镜像"只是文档里的一句话。 */
593
+ const asked = []
594
+ const recorder = {
595
+ fetch: async (request) => {
596
+ asked.push(request.url)
597
+ return { url: request.url, statusCode: 200, body: { kind: 'text', content: '{"version":"1.2.3"}' }, truncated: false }
598
+ },
599
+ }
600
+ const mirrorUrls = { npm: 'https://registry.npmmirror.com/dsh-wisp/latest' }
601
+ const mirror = await readPublishedVersion(recorder, mirrorUrls, undefined, 50)
602
+ check(mirror.ok === true && mirror.latest === '1.2.3' && mirror.from === 'npm'
603
+ && asked.length === 1 && asked[0] === mirrorUrls.npm,
604
+ 'the npm source can be pointed at a mirror through config (only the configured URL is asked)',
605
+ JSON.stringify({ asked, verdict: mirror }))
606
+
587
607
  const good = await readPublishedVersion(only('npm', { body: '{"name":"dsh-wisp","version":"9.9.9"}' }), U)
588
608
  check(good.ok === true && good.latest === '9.9.9' && good.from === 'npm',
589
609
  'a 200 with a version field is read correctly', JSON.stringify(good))
@@ -647,18 +667,71 @@ head('1b. the host half answers checkUpdate through the platform web service')
647
667
  /* ---- 服务在**调用时**解析,而不是吃启动那一刻的快照 ----
648
668
  宿主半包由 bundle patch 插进来,可能比 web 服务先就位;而 optional 依赖在启动时缺席
649
669
  并不会事后补上 —— `ctx.web` 会永远是 undefined,检查更新从此永久失效。
650
- `ctx.get('web')` 是调用时查表,所以这条竞态修得掉。 */
651
- const lateService = { fetch: async () => ({ statusCode: 200, body: { kind: 'text', content: '{"version":"9.9.9"}' } }) }
670
+ `ctx.get('web')` 是调用时查表,所以这条竞态修得掉。 */ const lateService = { fetch: async () => ({ statusCode: 200, body: { kind: 'text', content: '{"version":"9.9.9"}' } }) }
652
671
  const declaredService = { fetch: async () => ({ statusCode: 200, body: { kind: 'text', content: '{"version":"1.0.0"}' } }) }
653
- check(resolveWebService({ get: () => lateService, web: declaredService }) === lateService,
672
+ check(resolveWebService({ get: () => lateService, web: declaredService }).service === lateService,
654
673
  'the web service is resolved at CALL time, not from the startup-time property')
655
- check(resolveWebService({ get: () => undefined, web: declaredService }) === declaredService,
674
+ check(resolveWebService({ get: () => undefined, web: declaredService }).service === declaredService,
656
675
  'with ctx.get answering nothing, the declared property is still the fallback')
657
- check(resolveWebService({ get: () => { throw new Error('restricted ctx') }, web: declaredService }) === declaredService,
676
+ check(resolveWebService({ get: () => { throw new Error('restricted ctx') }, web: declaredService }).service === declaredService,
658
677
  'a throwing ctx.get falls back instead of breaking the check')
659
- check(resolveWebService({ web: { notAFetchService: true } }) === undefined
660
- && resolveWebService(undefined) === undefined,
661
- 'a shell with no usable web service reports undefined rather than crashing')
678
+ /* "有个对象但不是 web 服务"也算 partial:把形状说出来,让 readVersion 去报
679
+ "有服务但没有 fetch" —— 那比一句"没有通道"更能指认问题在哪一层。 */
680
+ check(resolveWebService({ web: { notAFetchService: true } }).how === 'partial'
681
+ && resolveWebService({ web: { notAFetchService: true } }).service !== undefined
682
+ && resolveWebService(undefined).service === undefined,
683
+ 'a non-service object is passed through as partial so the failure can name its shape')
684
+
685
+ /* ---- 拿不到服务时,必须能说清"看见了什么" ------------------------------------
686
+ 真机上出现过"两条路都拿不到、界面只说没有联网通道",而那句话没法追问。
687
+ 诊断至少要回答:ctx.get 是不是函数、拿到的东西有没有 fetch。 */
688
+ const blind = resolveWebService({ get: () => ({ search: () => {} }) })
689
+ check(blind.service !== undefined && blind.how === 'partial' && blind.why.includes('-fetch'),
690
+ 'a service without a callable fetch is reported as partial, with its shape spelled out',
691
+ JSON.stringify({ how: blind.how, why: blind.why }))
692
+ const nothing = resolveWebService({})
693
+ check(nothing.service === undefined && nothing.how === 'none'
694
+ && nothing.why.includes('ctx.get("web")→undefined') && nothing.why.includes('ctx.web→undefined'),
695
+ 'and a genuinely missing service says which lookups came back undefined', nothing.why)
696
+ const noGet = resolveWebService(undefined)
697
+ check(noGet.service === undefined && noGet.how === 'none', 'no ctx at all is reported, not thrown', noGet.why)
698
+
699
+ /* ---- 兜底通道:web 拿不到时问插件管理器"这个包是什么版本" ---------------------
700
+ 它走的是应用自己的网络出口(安装前 inspect 本来就要去 registry 问),
701
+ 所以这条路不依赖插件能否拿到 web 服务。 */
702
+ const managedCtx = {
703
+ get: (name) => (name === 'pluginManager'
704
+ ? { inspect: async (spec) => ({ status: 'accepted', name: spec, version: '7.7.7', registry: 'https://mirror.example' }) }
705
+ : undefined),
706
+ }
707
+ const managed = await readManagedVersion(managedCtx, 'dsh-wisp', 500)
708
+ check(managed.ok === true && managed.version === '7.7.7' && managed.registry === 'https://mirror.example',
709
+ 'the plugin manager can be asked for the published version as a fallback channel',
710
+ JSON.stringify(managed))
711
+ const noManager = await readManagedVersion({ get: () => undefined }, 'dsh-wisp', 500)
712
+ check(noManager.ok === false && noManager.reason === 'no-plugin-manager',
713
+ 'and its absence is a named reason, not an exception', JSON.stringify(noManager))
714
+ const refused = await readManagedVersion({ get: () => ({ inspect: async () => ({ status: 'refused', reason: 'no such package' }) }) }, 'dsh-wisp', 500)
715
+ check(refused.ok === false && refused.reason === 'inspect-refused' && refused.detail === 'no such package',
716
+ 'a refusal keeps the manager\'s own words', JSON.stringify(refused))
717
+
718
+ /* 端到端:没有 web 服务、但有插件管理器 → 检查更新照样能给出答案,并说明走的哪条通道 */
719
+ {
720
+ const handlers = new Map()
721
+ const originalHarness = globalThis.harness
722
+ globalThis.harness = { handle: (name, fn) => { handlers.set(name, fn); return () => handlers.delete(name) } }
723
+ try {
724
+ registerHandlers(managedCtx, { urls: U })
725
+ const viaManager = await handlers.get('checkUpdate')({ current: '1.0.0' })
726
+ check(viaManager.ok === true && viaManager.latest === '7.7.7' && viaManager.from === 'npm'
727
+ && viaManager.diag && viaManager.diag.via === 'pluginManager',
728
+ 'with no web service but a plugin manager, the check still answers — through the fallback',
729
+ JSON.stringify(viaManager))
730
+ } finally {
731
+ if (originalHarness === undefined) delete globalThis.harness
732
+ else globalThis.harness = originalHarness
733
+ }
734
+ }
662
735
 
663
736
  /* ---- 每个源一个自己的截止时间 ----
664
737
  共用一个信号时,一个卡住的源到点会把另一个**已经拿到结果**的源一起作废,
@@ -2021,13 +2094,13 @@ if (clientSrc !== null) {
2021
2094
  const size0 = ixApi.config.size
2022
2095
  wheel(-100, { ctrlKey: true })
2023
2096
  const sizeUp = ixApi.config.size
2024
- check(Math.abs(sizeUp - (size0 + 0.25)) < 1e-9, 'ctrl+wheel up makes her a step bigger',
2097
+ check(Math.abs(sizeUp - (size0 + 0.1)) < 1e-9, 'ctrl+wheel up makes her a step bigger',
2025
2098
  String(size0) + ' -> ' + String(sizeUp))
2026
2099
  wheel(100, { ctrlKey: true })
2027
2100
  check(Math.abs(ixApi.config.size - size0) < 1e-9, 'and ctrl+wheel down takes it back',
2028
2101
  String(ixApi.config.size))
2029
2102
  wheel(-100, { metaKey: true })
2030
- check(Math.abs(ixApi.config.size - (size0 + 0.25)) < 1e-9, '⌘+wheel does the same on macOS',
2103
+ check(Math.abs(ixApi.config.size - (size0 + 0.1)) < 1e-9, '⌘+wheel does the same on macOS',
2031
2104
  String(ixApi.config.size))
2032
2105
  wheel(100, { metaKey: true })
2033
2106
 
@@ -2203,6 +2276,8 @@ if (clientSrc !== null) {
2203
2276
  const diagHarness = mkChecker(async () => ({
2204
2277
  ok: false,
2205
2278
  reason: 'no-web-service',
2279
+ /* 宿主现在会一起带回"它看见了什么" —— 失败台词背后的细节靠这一段。 */
2280
+ diag: { via: 'none', how: 'none', why: 'ctx.get("web")→undefined; ctx.web→undefined', manager: 'no-plugin-manager' },
2206
2281
  sources: {
2207
2282
  npm: { ok: false, reason: 'no-web-service' },
2208
2283
  github: { ok: false, reason: 'fetch-failed', detail: 'getaddrinfo ENOTFOUND' },
@@ -2220,6 +2295,19 @@ if (clientSrc !== null) {
2220
2295
  && diagLast.sources.github.detail === 'getaddrinfo ENOTFOUND',
2221
2296
  'a failed check keeps every source reason, so doctor() can say WHY it failed',
2222
2297
  JSON.stringify(diagLast))
2298
+
2299
+ /* 台词是给人读的一句话,技术细节不进嘴;但细节必须看得见 —— 失败时排进弹窗。
2300
+ (真机上出现过台词后面挂一串 ctx.get(...)→undefined 的英文碎片。) */
2301
+ diagHarness.win.__wisp.showUpdate()
2302
+ await new Promise((resolve) => setImmediate(resolve))
2303
+ diagHarness.advance(200, 50)
2304
+ const failSaid = String(diagHarness.all('wisp-say').at(-1)?.textContent ?? '')
2305
+ check(!/[A-Za-z]{3,}/.test(failSaid),
2306
+ 'the failure line stays human — no technical fragments in the spoken sentence', failSaid)
2307
+ const diagDialog = diagHarness.all('wisp-dialog-p').map((el) => el.textContent).join('\n')
2308
+ check(diagDialog.includes('看到的东西') && diagDialog.includes('取服务的方式'),
2309
+ 'and the dialog spells out what the host actually saw',
2310
+ diagDialog.replace(/\n/g, ' | ').slice(0, 120))
2223
2311
  diagHarness.win.__wisp.destroy()
2224
2312
  active = keepDiag
2225
2313
 
@@ -2258,6 +2346,27 @@ if (clientSrc !== null) {
2258
2346
  check(new Set([noChannelSaid, noHostSaid, networkSaid]).size === 3,
2259
2347
  'the three failure kinds really are three different sentences')
2260
2348
 
2349
+ /* "没接上宿主"和"宿主拿不到服务"是链路上**不同的一环**断了 —— 必须分开说,
2350
+ 否则那句话永远说不清断在哪(1.43.3 之前两者共用同一句,真机上就卡在这)。 */
2351
+ const noSeat = createHarness({ timer: true, composerText: '' }) // 默认替身没有 host 座位
2352
+ const keepNoSeat = active
2353
+ active = noSeat
2354
+ noSeat.evaluate(clientSrc)
2355
+ noSeat.module().default.apply(noSeat.ctx, { reactions: false, wander: false, celebrate: false })
2356
+ noSeat.advance(1000, 100)
2357
+ const noSeatRes = await noSeat.win.__wisp.checkForUpdate()
2358
+ noSeat.win.__wisp.showUpdate()
2359
+ await new Promise((resolve) => setImmediate(resolve))
2360
+ noSeat.advance(200, 50)
2361
+ const noSeatSaid = String(noSeat.all('wisp-say').at(-1)?.textContent ?? '')
2362
+ check(noSeatRes.state === 'failed' && noSeatRes.reason === 'fetch-failed',
2363
+ 'no host seat at all gets its OWN sentence — the first link in the chain',
2364
+ `${noSeatRes.state} | ${noSeatSaid}`)
2365
+ check(noSeatSaid !== noChannelSaid,
2366
+ 'and it is distinguishable from "the host ran but has no web service"', `${noSeatSaid} ≠ ${noChannelSaid}`)
2367
+ noSeat.win.__wisp.destroy()
2368
+ active = keepNoSeat
2369
+
2261
2370
  /* 宿主调用抛错:不能变成未处理的 rejection */
2262
2371
  const thrower = mkChecker('boom')
2263
2372
  const keepThrower = active
@@ -2279,8 +2388,9 @@ if (clientSrc !== null) {
2279
2388
  noHost.module().default.apply(noHost.ctx, { reactions: true, wander: false, celebrate: false })
2280
2389
  noHost.advance(1300, 100)
2281
2390
  const unsupported = await noHost.win.__wisp.checkForUpdate()
2282
- check(unsupported.state === 'unsupported',
2283
- 'with no host seat she reports that checking is not available', JSON.stringify(unsupported))
2391
+ check(noSeatRes.state === 'failed' && noSeatRes.reason === 'fetch-failed',
2392
+ 'with no host seat the CLIENT half falls back to its own fetch (no more unsupported)',
2393
+ JSON.stringify(noSeatRes))
2284
2394
  const stillWorks = noHost.win.__wisp.showUpdate()
2285
2395
  check(stillWorks.version === pkgVersion && stillWorks.checking === true,
2286
2396
  'and the update entry still works without it', JSON.stringify(stillWorks))
@@ -3108,8 +3218,8 @@ if (clientSrc !== null) {
3108
3218
  check(focusSlider(), 'the slider is reachable with the arrow keys',
3109
3219
  h.document.activeElement ? String(h.document.activeElement.className) : '(没有焦点)')
3110
3220
  pressKey('ArrowRight')
3111
- check(Math.abs(fifth.config.size - (sizeBefore + 0.25)) < 1e-9,
3112
- 'ArrowRight grows her by a quarter step', `${sizeBefore} -> ${fifth.config.size}`)
3221
+ check(Math.abs(fifth.config.size - (sizeBefore + 0.1)) < 1e-9,
3222
+ 'ArrowRight grows her by exactly one step', `${sizeBefore} -> ${fifth.config.size}`)
3113
3223
  check(menuOf() !== null && focusedIsSlider(),
3114
3224
  'and it neither closes the menu nor steals focus from the slider',
3115
3225
  menuOf() === null ? '菜单关了' : String(h.document.activeElement && h.document.activeElement.className))
@@ -3307,7 +3417,7 @@ if (clientSrc !== null) {
3307
3417
  }
3308
3418
  h.win.dispatch('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} })
3309
3419
  h.win.dispatch('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} })
3310
- check(fifth.element.style.width === '630px' && fifth.element.style.height === '945px',
3420
+ check(fifth.element.style.width === '588px' && fifth.element.style.height === '882px',
3311
3421
  'the slider really resized her (two steps up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
3312
3422
  check(menuOf() !== null, 'and the slider keeps the menu open, unlike an action row')
3313
3423
  h.win.dispatch('keydown', { key: 'Escape', preventDefault() {}, stopPropagation() {} })
@@ -4187,6 +4297,33 @@ head('3y-3. the focus timer, and going quiet while the page is hidden')
4187
4297
  check(typeof quietApi.today.runs === 'number' && quietApi.today.runs >= 1,
4188
4298
  'the day book is also readable from the api', JSON.stringify(quietApi.today))
4189
4299
 
4300
+ /* ---- 语言判定不能取决于 Node 版本 -------------------------------------------
4301
+ Node 21+ 自带全局 navigator(navigator.language = 系统语言)。harness 若不把它注入到
4302
+ 被求值的作用域里,客户端读到的就是 Node 的 —— 于是同一份代码在中文开发机上绿、
4303
+ 在英文 runner 上红。这条把 Node 的 navigator 特意伪装成 en-US,再断言她仍然按
4304
+ harness 自己的(没有 language 的)navigator 说中文。 */
4305
+ const nodeNav = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
4306
+ try {
4307
+ Object.defineProperty(globalThis, 'navigator', {
4308
+ value: { language: 'en-US', languages: ['en-US'] }, configurable: true, writable: true,
4309
+ })
4310
+ const nodeNavHarness = createHarness({ timer: true, composerText: '' })
4311
+ const keepNodeNav = active
4312
+ active = nodeNavHarness
4313
+ nodeNavHarness.evaluate(clientSrc)
4314
+ nodeNavHarness.module().default.apply(nodeNavHarness.ctx, { reactions: false, wander: false, celebrate: false })
4315
+ nodeNavHarness.advance(1000, 200)
4316
+ const nodeNavSaid = String(nodeNavHarness.all('wisp-say').at(-1)?.textContent ?? '')
4317
+ check(/[\u4e00-\u9fa5]/.test(nodeNavSaid),
4318
+ 'the harness hands the client its OWN navigator — Node 21+ has a global one and would leak its locale',
4319
+ `${nodeNavSaid.slice(0, 40)} | Node navigator=${globalThis.navigator.language}`)
4320
+ nodeNavHarness.win.__wisp.destroy()
4321
+ active = keepNodeNav
4322
+ } finally {
4323
+ if (nodeNav) Object.defineProperty(globalThis, 'navigator', nodeNav)
4324
+ else delete globalThis.navigator
4325
+ }
4326
+
4190
4327
  /* ---- 英文台词:英文环境下她说英文,而且**一个汉字都不该出现** -------------------
4191
4328
  语言是加载时判定的(读 navigator.language),所以这个 harness 要在 evaluate 之前
4192
4329
  把 navigator 摆好。中文是兜底:没翻的条目仍然会说中文,这一条只验"翻译层真的生效"。 */
@@ -4319,6 +4456,143 @@ if (existsSync(join(here, 'README.md'))) {
4319
4456
  undeclared.length ? undeclared.join(' | ') : `${mentioned.length} tool(s) referenced, all in files[]`)
4320
4457
  }
4321
4458
 
4459
+ /* ---------------------------------------------------------------------------
4460
+ 1.43.0:一批修复各自的不变式。
4461
+
4462
+ 每条修复背后都有一句"如果再犯就会露出来"的断言 —— 没有它,这些修复会在下一次
4463
+ 重构里悄悄退回原样(这批里有三条正是这么来的)。 */
4464
+ {
4465
+ const h = createHarness({ timer: true, composerText: '', wanderMs: 150, wanderRange: 200 })
4466
+ const keep = active
4467
+ active = h
4468
+ h.evaluate(clientSrc)
4469
+ h.module().default.apply(h.ctx, { reactions: false, wander: true, celebrate: false })
4470
+ h.advance(1200, 100)
4471
+ const api = h.win.__wisp
4472
+ const root = h.document.querySelector('.wisp-root')
4473
+ const tiltDeg = () => {
4474
+ const n = Number(String(root.style.getPropertyValue('--wisp-tilt') || '0deg').replace('deg', ''))
4475
+ return Number.isFinite(n) ? n : 0
4476
+ }
4477
+
4478
+ /* 脚底光晕在 motion 层、不在 lean 层:歪的是她,不该是地板 */
4479
+ const shineEl = h.document.querySelector('.wisp-shine')
4480
+ const motionEl = h.document.querySelector('.wisp-motion')
4481
+ check(shineEl !== null && motionEl !== null && shineEl.parentNode === motionEl,
4482
+ 'the ground glow lives outside the tilt layer',
4483
+ shineEl && shineEl.parentNode ? String(shineEl.parentNode.className) : '(missing)')
4484
+
4485
+ /* 静止档的 z:静止 ≠ 冻成满不透明 */
4486
+ check(clientSrc.includes('.wisp-root[data-motion="off"] .wisp-zzz{opacity:.5}'),
4487
+ 'the still level shows a subdued z instead of a frozen opaque one')
4488
+ const rmAt = clientSrc.indexOf('@media (prefers-reduced-motion:reduce)')
4489
+ check(rmAt >= 0 && clientSrc.slice(rmAt, rmAt + 800).includes('.wisp-zzz{animation:none!important;opacity:.5}'),
4490
+ 'and reduced motion does the same for it')
4491
+
4492
+ /* 滑块的默认值必须落在步长格点上(否则用户动了就回不到出厂大小) */
4493
+ const wheelStep = Number((/const WHEEL_STEP = ([0-9.]+)/.exec(clientSrc) ?? [])[1])
4494
+ const keyStep = Number((/ev\.shiftKey \? 1 : ([0-9.]+)/.exec(clientSrc) ?? [])[1])
4495
+ const sizeLo = Number((/size: \{ kind: 'num', lo: ([0-9.]+)/.exec(clientSrc) ?? [])[1])
4496
+ const defaultsBlock = clientSrc.match(/const DEFAULTS = \{([\s\S]*?)\n {4}\}/)
4497
+ const defSize = Number((/size:\s*([0-9.]+)/.exec(defaultsBlock ? defaultsBlock[1] : '') ?? [])[1])
4498
+ check(wheelStep > 0 && wheelStep === keyStep, 'the wheel and the slider share exactly one step',
4499
+ `wheel=${wheelStep} key=${keyStep}`)
4500
+ const steps = (defSize - sizeLo) / wheelStep
4501
+ check(Number.isFinite(steps) && Math.abs(steps - Math.round(steps)) < 1e-9,
4502
+ 'the DEFAULT size is reachable from lo in whole steps — otherwise a user can never get back to it',
4503
+ `lo=${sizeLo} default=${defSize} step=${wheelStep} → ${steps} steps`)
4504
+
4505
+ /* 鼠标路过会侧身;手指路过不会(而且会把姿势归零)。
4506
+ y 必须取她的**中线**:侧身有"从正上方路过不倾"这一层(level 因子),
4507
+ 在脚下 700px 处派发事件本来就该是 0 度 —— 那样测的是另一条规则。 */
4508
+ const nearX = api.position.x + 280
4509
+ const nearY = api.position.y + 420
4510
+ h.win.dispatch('pointermove', { clientX: nearX + 24, clientY: nearY, pointerType: 'mouse' })
4511
+ check(tiltDeg() !== 0, 'a mouse passing by still leans her', `${tiltDeg()}deg`)
4512
+ h.win.dispatch('pointermove', { clientX: nearX + 24, clientY: nearY, pointerType: 'touch' })
4513
+ check(tiltDeg() === 0, 'a finger passing by does not — and clears the pose', `${tiltDeg()}deg`)
4514
+
4515
+ /* 指针离开文档:姿势归零(blur 只覆盖"切走应用") */
4516
+ h.win.dispatch('pointermove', { clientX: nearX + 24, clientY: nearY, pointerType: 'mouse' })
4517
+ check(tiltDeg() !== 0, 'leaning again before the pointer leaves', `${tiltDeg()}deg`)
4518
+ h.document.dispatch('mouseleave', {})
4519
+ check(tiltDeg() === 0, 'the pose resets when the pointer leaves the document', `${tiltDeg()}deg`)
4520
+
4521
+ /* 撞到边界不再"原地歪":位置没变就不该有侧倾 */
4522
+ api.move(1360, 300)
4523
+ h.win.dispatch('pointerdown', {
4524
+ clientX: 1360 + 280, clientY: 300 + 700, button: 0, pointerType: 'mouse',
4525
+ preventDefault() {}, stopPropagation() {},
4526
+ })
4527
+ h.win.dispatch('pointermove', { clientX: 1360 + 280 + 60, clientY: 300 + 700, pointerType: 'mouse' })
4528
+ check(api.position.x === 1360, 'a drag into the right edge stays clamped', String(api.position.x))
4529
+ check(tiltDeg() === 0, 'and a clamped drag does not tilt her in place', `${tiltDeg()}deg`)
4530
+ h.win.dispatch('pointerup', {})
4531
+
4532
+ /* 静止档也要停住 JS 溜达 —— CSS 关不掉坐标写入 */
4533
+ api.configure({ motion: 'off', wander: true, wanderMs: 150, wanderRange: 200 })
4534
+ const still = { x: api.position.x, y: api.position.y }
4535
+ h.advance(4000, 100)
4536
+ check(api.position.x === still.x && api.position.y === still.y,
4537
+ 'motion: off stops the walk too, not only the animation', `${api.position.x},${api.position.y}`)
4538
+ api.configure({ motion: 'full' })
4539
+ h.advance(6000, 100)
4540
+ check(api.position.x !== still.x || api.position.y !== still.y,
4541
+ 'and with motion back on she strolls again', `${api.position.x},${api.position.y}`)
4542
+
4543
+ /* 拨开关之后子面板要留在原地(点开的就该一直开着)。
4544
+ contextmenu 要派给**她的本体**(监听挂在 body 上),派给 window 不会走到那一层。 */
4545
+ h.document.querySelector('.wisp-body').dispatch('contextmenu', {
4546
+ clientX: api.position.x + 280, clientY: api.position.y + 420,
4547
+ preventDefault() {}, stopPropagation() {},
4548
+ })
4549
+ const groupRow = h.all('wisp-menu-item').find((el) => el.dataset && el.dataset.group === '行为')
4550
+ check(groupRow !== undefined, 'the behaviour group row is there to click', String(h.all('wisp-menu-item').length) + ' rows')
4551
+ if (groupRow) {
4552
+ groupRow.dispatch('click', { preventDefault() {}, stopPropagation() {} })
4553
+ h.advance(60, 20)
4554
+ const flyoutBefore = h.all('wisp-submenu').length
4555
+ const aSwitch = h.all('wisp-switch')[0]
4556
+ check(flyoutBefore > 0 && aSwitch !== undefined, 'its flyout is open with switches in it', String(flyoutBefore))
4557
+ if (aSwitch) {
4558
+ aSwitch.dispatch('click', { preventDefault() {}, stopPropagation() {} })
4559
+ h.advance(60, 20)
4560
+ check(h.all('wisp-submenu').length > 0,
4561
+ 'toggling a switch keeps the flyout open — the panel vanishing is the most annoying version of this',
4562
+ `${h.all('wisp-submenu').length} panel(s)`)
4563
+ }
4564
+ }
4565
+ api.destroy()
4566
+ active = keep
4567
+ }
4568
+
4569
+ /* GitHub 上更新、npm 上还是旧版时:提示要改口,并且复制的是仓库地址而不是包名 */
4570
+ {
4571
+ const gh = createHarness({
4572
+ timer: true, composerText: '', clipboard: true,
4573
+ hostCall: async () => ({
4574
+ ok: true, latest: '9.9.9', from: 'github',
4575
+ sources: { npm: { ok: true, version: '1.0.0' }, github: { ok: true, version: '9.9.9' } },
4576
+ }),
4577
+ })
4578
+ const keepGh = active
4579
+ active = gh
4580
+ gh.evaluate(clientSrc)
4581
+ gh.module().default.apply(gh.ctx, { reactions: false, wander: false, celebrate: false })
4582
+ gh.advance(1200, 100)
4583
+ gh.win.__wisp.showUpdate()
4584
+ await new Promise((resolve) => setImmediate(resolve))
4585
+ gh.advance(200, 50)
4586
+ check(gh.clipboardWrites[gh.clipboardWrites.length - 1] === 'https://github.com/969246694/dsh-wisp',
4587
+ 'when only GitHub has the new version she copies the repo URL, not the package name that installs the old one',
4588
+ JSON.stringify(gh.clipboardWrites))
4589
+ check(String(gh.win.__wisp.doctor().update.hint).includes('npm 上还是旧版'),
4590
+ 'and doctor() records which hint she actually used',
4591
+ String(gh.win.__wisp.doctor().update.hint))
4592
+ gh.win.__wisp.destroy()
4593
+ active = keepGh
4594
+ }
4595
+
4322
4596
  if (clientSrc !== null) {
4323
4597
  /* ---------------------------------------------------------------------------
4324
4598
  配置键是这套"多端"里最容易漂的一处:它在**三个地方**各出现一次 ——
@@ -4440,6 +4714,26 @@ if (existsSync(join(here, 'README.md'))) {
4440
4714
  check(readmeVersion === pkg.version, 'the README header quotes the packaged version',
4441
4715
  `README ${readmeVersion ?? '(没写)'} vs package ${pkg.version}`)
4442
4716
 
4717
+ /* 客户端自己抓的那条路必须真的能成:给它一个可用的 window.fetch,她要报出版本。
4718
+ 这就是 1.44.0 的全部要点 —— bundle 客户端半包是普通页面脚本,fetch 可用。 */
4719
+ {
4720
+ const fetched = createHarness({ timer: true, composerText: '' })
4721
+ const keepFetched = active
4722
+ active = fetched
4723
+ fetched.win.fetch = (url) => Promise.resolve({
4724
+ ok: true,
4725
+ json: () => Promise.resolve(String(url).indexOf('raw.githubusercontent') >= 0 ? { version: '1.0.0' } : { version: '9.9.9' }),
4726
+ })
4727
+ fetched.evaluate(clientSrc)
4728
+ fetched.module().default.apply(fetched.ctx, { reactions: false, wander: false, celebrate: false })
4729
+ fetched.advance(1000, 100)
4730
+ const clientRes = await fetched.win.__wisp.checkForUpdate()
4731
+ check(clientRes.state === 'available' && clientRes.latest === '9.9.9' && clientRes.from === 'npm',
4732
+ 'with a usable window.fetch the CLIENT half does the whole check itself — no host RPC involved',
4733
+ JSON.stringify(clientRes))
4734
+ fetched.win.__wisp.destroy()
4735
+ active = keepFetched
4736
+ }
4443
4737
  const quoted = Number((/当前 \*\*(\d+) 项全 PASS/.exec(readmeText) ?? [])[1])
4444
4738
  const total = checks + 1
4445
4739
  if (quoted === total) {