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/README.md +84 -3
- package/cordis.patch.yml +5 -0
- package/lib/client.js +192 -15
- package/lib/client.template.js +192 -15
- package/lib/index.js +118 -16
- package/package.json +1 -1
- package/verify-wisp.mjs +313 -19
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
2283
|
-
'with no host seat
|
|
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.
|
|
3112
|
-
'ArrowRight grows her by
|
|
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 === '
|
|
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) {
|