dsh-dbhub-live 5.0.1 → 5.2.0-dev.3
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/AGENTS.md +95 -209
- package/README.en.md +251 -169
- package/README.md +251 -169
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +177 -105
- package/doc/images/chat-query.png +0 -0
- package/doc/images/row-editor.png +0 -0
- package/doc/images/settings-page.png +0 -0
- package/lib/adhoc.mjs +412 -34
- package/lib/capability.mjs +157 -0
- package/lib/client.js +1243 -80
- package/lib/config.mjs +370 -21
- package/lib/i18n.mjs +119 -4
- package/lib/index.mjs +401 -43
- package/lib/mcp.mjs +496 -391
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +25 -5
- package/lib/policy.mjs +92 -0
- package/lib/toml.mjs +188 -0
- package/lib/tools.mjs +1093 -827
- package/lib/update.mjs +244 -0
- package/package.json +34 -10
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +255 -237
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +1366 -843
- package/test/adhoc-toml.test.mjs +365 -0
- package/test/bridge.test.mjs +227 -185
- package/test/capability.test.mjs +231 -0
- package/test/client-format.test.mjs +515 -352
- package/test/configure-options.test.mjs +244 -0
- package/test/configure-ssh.test.mjs +232 -0
- package/test/copy-from.test.mjs +155 -0
- package/test/env-options.test.mjs +456 -0
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +160 -123
- package/test/policy.test.mjs +132 -0
- package/test/row-cache.test.mjs +237 -138
- package/test/ssh-probe.test.mjs +834 -0
- package/test/test-op-fastpath.test.mjs +208 -0
- package/test/toml.test.mjs +352 -0
- package/test/update.test.mjs +270 -0
- package/test/util.test.mjs +17 -0
- package/test/zero-knowledge.test.mjs +120 -78
package/scripts/render-check.mjs
CHANGED
|
@@ -1,843 +1,1366 @@
|
|
|
1
|
-
// Offline render/interaction gate for dsh-dbhub-live's client bundle.
|
|
2
|
-
//
|
|
3
|
-
// React is not a dependency of this repository, so this script provides a
|
|
4
|
-
// minimal element factory + hook shim that runs the hand-authored bundle's page
|
|
5
|
-
// component for real: it walks the returned element tree and dispatches the
|
|
6
|
-
// onClick handlers a user would click. It covers the layout contract that the
|
|
7
|
-
// regex-level `test/client-format.test.mjs` cannot — which mode (tiles vs
|
|
8
|
-
// grouped) a data shape selects, default expansion, expand/collapse-all,
|
|
9
|
-
// add-panel behaviour, the copy button, and that no raw i18n key or unfilled
|
|
10
|
-
// placeholder reaches the page in either language.
|
|
11
|
-
//
|
|
12
|
-
// Run: npm run check:ui (Node >= 18, no dependencies)
|
|
13
|
-
|
|
14
|
-
import { readFileSync } from 'node:fs'
|
|
15
|
-
import { fileURLToPath } from 'node:url'
|
|
16
|
-
|
|
17
|
-
const CLIENT = fileURLToPath(new URL('../lib/client.js', import.meta.url))
|
|
18
|
-
|
|
19
|
-
// ── minimal react ──────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
const react = (() => {
|
|
22
|
-
const instances = []
|
|
23
|
-
let cursor = 0
|
|
24
|
-
let current = null
|
|
25
|
-
let dirty = false
|
|
26
|
-
let pendingEffects = []
|
|
27
|
-
|
|
28
|
-
function createElement(type, props, ...children) {
|
|
29
|
-
const next = Object.assign({}, props || {})
|
|
30
|
-
if (children.length === 1) next.children = children[0]
|
|
31
|
-
else if (children.length > 1) next.children = children
|
|
32
|
-
return { type, props: next }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const slot = () => {
|
|
36
|
-
if (!current) throw new Error('hook used outside a component render')
|
|
37
|
-
return current
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function useState(init) {
|
|
41
|
-
const s = slot()
|
|
42
|
-
const i = cursor++
|
|
43
|
-
if (!(i in s.hooks)) s.hooks[i] = typeof init === 'function' ? init() : init
|
|
44
|
-
const set = (value) => {
|
|
45
|
-
s.hooks[i] = typeof value === 'function' ? value(s.hooks[i]) : value
|
|
46
|
-
dirty = true
|
|
47
|
-
}
|
|
48
|
-
return [s.hooks[i], set]
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function useRef(init) {
|
|
52
|
-
const s = slot()
|
|
53
|
-
const i = cursor++
|
|
54
|
-
if (!(i in s.hooks)) s.hooks[i] = { current: init }
|
|
55
|
-
return s.hooks[i]
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function useEffect(fn, deps) {
|
|
59
|
-
const s = slot()
|
|
60
|
-
const i = cursor++
|
|
61
|
-
const prev = s.hooks[i]
|
|
62
|
-
const changed = !prev || !deps || deps.some((d, k) => d !== prev.deps[k])
|
|
63
|
-
s.hooks[i] = { deps: deps || [], cleanup: prev ? prev.cleanup : undefined, run: changed ? fn : null }
|
|
64
|
-
if (changed) pendingEffects.push(s.hooks[i])
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function walk(el, path) {
|
|
68
|
-
if (el === null || el === undefined || el === false || el === true) return null
|
|
69
|
-
if (typeof el === 'string' || typeof el === 'number') return { kind: 'text', text: String(el) }
|
|
70
|
-
if (Array.isArray(el)) {
|
|
71
|
-
return { kind: 'frag', children: el.map((c, i) => walk(c, `${path}.${i}`)).filter(Boolean) }
|
|
72
|
-
}
|
|
73
|
-
if (typeof el.type === 'function') {
|
|
74
|
-
const idx = cursor++
|
|
75
|
-
const s = instances[idx] || (instances[idx] = { hooks: [] })
|
|
76
|
-
const savedCurrent = current
|
|
77
|
-
const savedCursor = cursor
|
|
78
|
-
current = s
|
|
79
|
-
cursor = 0
|
|
80
|
-
const out = el.type(el.props)
|
|
81
|
-
current = savedCurrent
|
|
82
|
-
cursor = savedCursor
|
|
83
|
-
return walk(out, path)
|
|
84
|
-
}
|
|
85
|
-
const kids = el.props && el.props.children
|
|
86
|
-
const list = kids === undefined ? [] : Array.isArray(kids) ? kids : [kids]
|
|
87
|
-
return {
|
|
88
|
-
kind: 'el',
|
|
89
|
-
type: el.type,
|
|
90
|
-
props: el.props || {},
|
|
91
|
-
children: list.map((c, i) => walk(c, `${path}.${i}`)).filter(Boolean),
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Drop all hook state: call before mounting a NEW root component. */
|
|
96
|
-
function reset() {
|
|
97
|
-
instances.length = 0
|
|
98
|
-
pendingEffects = []
|
|
99
|
-
dirty = false
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Run every stored effect cleanup — what a real unmount does. */
|
|
103
|
-
function unmount() {
|
|
104
|
-
for (const s of instances) {
|
|
105
|
-
for (const hook of s.hooks) {
|
|
106
|
-
if (hook && typeof hook.cleanup === 'function') {
|
|
107
|
-
try { hook.cleanup() } catch (e) { /* cleanup must not break the gate */ }
|
|
108
|
-
hook.cleanup = undefined
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Render `type` (and re-render until no state setter fired). */
|
|
115
|
-
function render(type, props) {
|
|
116
|
-
let tree
|
|
117
|
-
do {
|
|
118
|
-
dirty = false
|
|
119
|
-
cursor = 0
|
|
120
|
-
const s = instances[0] || (instances[0] = { hooks: [] })
|
|
121
|
-
const savedCurrent = current
|
|
122
|
-
current = s
|
|
123
|
-
cursor = 1
|
|
124
|
-
const out = type(props)
|
|
125
|
-
current = savedCurrent
|
|
126
|
-
tree = walk(out, '')
|
|
127
|
-
const runnable = pendingEffects.filter((e) => e.run)
|
|
128
|
-
pendingEffects = []
|
|
129
|
-
for (const e of runnable) {
|
|
130
|
-
const cleanup = e.run()
|
|
131
|
-
if (typeof cleanup === 'function') e.cleanup = cleanup
|
|
132
|
-
}
|
|
133
|
-
} while (dirty)
|
|
134
|
-
return tree
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return { createElement, useState, useRef, useEffect, render, reset, unmount }
|
|
138
|
-
})()
|
|
139
|
-
|
|
140
|
-
// ── load the bundle through its ModuleLoader contract ──────────────────────
|
|
141
|
-
|
|
142
|
-
const primitivesStub = (() => {
|
|
143
|
-
const icon = (cls) => (props) => react.createElement('svg', Object.assign({ className: cls }, props))
|
|
144
|
-
return {
|
|
145
|
-
IconChevronDownOutline14: icon('ico-chev'),
|
|
146
|
-
IconCopyOutline16: icon('ico-copy'),
|
|
147
|
-
IconEditOutline16: icon('ico-edit'),
|
|
148
|
-
IconCloseOutline16: icon('ico-close'),
|
|
149
|
-
IconTrashOutline16: icon('ico-trash'),
|
|
150
|
-
IconPlayOutline16: icon('ico-run'),
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if (!hit) throw new Error(`no clickable element
|
|
368
|
-
hit.props.onClick({ stopPropagation() {}, preventDefault() {} })
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
]
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
{
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
{
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
{
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
click
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
p.tree
|
|
536
|
-
check('
|
|
537
|
-
|
|
538
|
-
check('
|
|
539
|
-
|
|
540
|
-
check('
|
|
541
|
-
|
|
542
|
-
p.tree
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
check('
|
|
548
|
-
check('
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
check('
|
|
559
|
-
check('
|
|
560
|
-
|
|
561
|
-
p.tree
|
|
562
|
-
check('
|
|
563
|
-
check('
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
check('
|
|
574
|
-
check('
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
console.log('\n[
|
|
578
|
-
{
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
check('
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
console.log('\n[
|
|
594
|
-
{
|
|
595
|
-
const p = page(
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
}
|
|
602
|
-
check('
|
|
603
|
-
click(p.tree, '
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
check('
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
{
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
check('
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
check('
|
|
650
|
-
check('
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
check('
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
check('
|
|
694
|
-
check('
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
check('
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
check('
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
check('
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
check('
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
check('
|
|
770
|
-
check('
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
check('
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
check('
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const p = page(
|
|
796
|
-
|
|
797
|
-
p.tree
|
|
798
|
-
|
|
799
|
-
check('
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
check('
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
p.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
check('
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1
|
+
// Offline render/interaction gate for dsh-dbhub-live's client bundle.
|
|
2
|
+
//
|
|
3
|
+
// React is not a dependency of this repository, so this script provides a
|
|
4
|
+
// minimal element factory + hook shim that runs the hand-authored bundle's page
|
|
5
|
+
// component for real: it walks the returned element tree and dispatches the
|
|
6
|
+
// onClick handlers a user would click. It covers the layout contract that the
|
|
7
|
+
// regex-level `test/client-format.test.mjs` cannot — which mode (tiles vs
|
|
8
|
+
// grouped) a data shape selects, default expansion, expand/collapse-all,
|
|
9
|
+
// add-panel behaviour, the copy button, and that no raw i18n key or unfilled
|
|
10
|
+
// placeholder reaches the page in either language.
|
|
11
|
+
//
|
|
12
|
+
// Run: npm run check:ui (Node >= 18, no dependencies)
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const CLIENT = fileURLToPath(new URL('../lib/client.js', import.meta.url))
|
|
18
|
+
|
|
19
|
+
// ── minimal react ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const react = (() => {
|
|
22
|
+
const instances = []
|
|
23
|
+
let cursor = 0
|
|
24
|
+
let current = null
|
|
25
|
+
let dirty = false
|
|
26
|
+
let pendingEffects = []
|
|
27
|
+
|
|
28
|
+
function createElement(type, props, ...children) {
|
|
29
|
+
const next = Object.assign({}, props || {})
|
|
30
|
+
if (children.length === 1) next.children = children[0]
|
|
31
|
+
else if (children.length > 1) next.children = children
|
|
32
|
+
return { type, props: next }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const slot = () => {
|
|
36
|
+
if (!current) throw new Error('hook used outside a component render')
|
|
37
|
+
return current
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function useState(init) {
|
|
41
|
+
const s = slot()
|
|
42
|
+
const i = cursor++
|
|
43
|
+
if (!(i in s.hooks)) s.hooks[i] = typeof init === 'function' ? init() : init
|
|
44
|
+
const set = (value) => {
|
|
45
|
+
s.hooks[i] = typeof value === 'function' ? value(s.hooks[i]) : value
|
|
46
|
+
dirty = true
|
|
47
|
+
}
|
|
48
|
+
return [s.hooks[i], set]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function useRef(init) {
|
|
52
|
+
const s = slot()
|
|
53
|
+
const i = cursor++
|
|
54
|
+
if (!(i in s.hooks)) s.hooks[i] = { current: init }
|
|
55
|
+
return s.hooks[i]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function useEffect(fn, deps) {
|
|
59
|
+
const s = slot()
|
|
60
|
+
const i = cursor++
|
|
61
|
+
const prev = s.hooks[i]
|
|
62
|
+
const changed = !prev || !deps || deps.some((d, k) => d !== prev.deps[k])
|
|
63
|
+
s.hooks[i] = { deps: deps || [], cleanup: prev ? prev.cleanup : undefined, run: changed ? fn : null }
|
|
64
|
+
if (changed) pendingEffects.push(s.hooks[i])
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function walk(el, path) {
|
|
68
|
+
if (el === null || el === undefined || el === false || el === true) return null
|
|
69
|
+
if (typeof el === 'string' || typeof el === 'number') return { kind: 'text', text: String(el) }
|
|
70
|
+
if (Array.isArray(el)) {
|
|
71
|
+
return { kind: 'frag', children: el.map((c, i) => walk(c, `${path}.${i}`)).filter(Boolean) }
|
|
72
|
+
}
|
|
73
|
+
if (typeof el.type === 'function') {
|
|
74
|
+
const idx = cursor++
|
|
75
|
+
const s = instances[idx] || (instances[idx] = { hooks: [] })
|
|
76
|
+
const savedCurrent = current
|
|
77
|
+
const savedCursor = cursor
|
|
78
|
+
current = s
|
|
79
|
+
cursor = 0
|
|
80
|
+
const out = el.type(el.props)
|
|
81
|
+
current = savedCurrent
|
|
82
|
+
cursor = savedCursor
|
|
83
|
+
return walk(out, path)
|
|
84
|
+
}
|
|
85
|
+
const kids = el.props && el.props.children
|
|
86
|
+
const list = kids === undefined ? [] : Array.isArray(kids) ? kids : [kids]
|
|
87
|
+
return {
|
|
88
|
+
kind: 'el',
|
|
89
|
+
type: el.type,
|
|
90
|
+
props: el.props || {},
|
|
91
|
+
children: list.map((c, i) => walk(c, `${path}.${i}`)).filter(Boolean),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Drop all hook state: call before mounting a NEW root component. */
|
|
96
|
+
function reset() {
|
|
97
|
+
instances.length = 0
|
|
98
|
+
pendingEffects = []
|
|
99
|
+
dirty = false
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Run every stored effect cleanup — what a real unmount does. */
|
|
103
|
+
function unmount() {
|
|
104
|
+
for (const s of instances) {
|
|
105
|
+
for (const hook of s.hooks) {
|
|
106
|
+
if (hook && typeof hook.cleanup === 'function') {
|
|
107
|
+
try { hook.cleanup() } catch (e) { /* cleanup must not break the gate */ }
|
|
108
|
+
hook.cleanup = undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Render `type` (and re-render until no state setter fired). */
|
|
115
|
+
function render(type, props) {
|
|
116
|
+
let tree
|
|
117
|
+
do {
|
|
118
|
+
dirty = false
|
|
119
|
+
cursor = 0
|
|
120
|
+
const s = instances[0] || (instances[0] = { hooks: [] })
|
|
121
|
+
const savedCurrent = current
|
|
122
|
+
current = s
|
|
123
|
+
cursor = 1
|
|
124
|
+
const out = type(props)
|
|
125
|
+
current = savedCurrent
|
|
126
|
+
tree = walk(out, '')
|
|
127
|
+
const runnable = pendingEffects.filter((e) => e.run)
|
|
128
|
+
pendingEffects = []
|
|
129
|
+
for (const e of runnable) {
|
|
130
|
+
const cleanup = e.run()
|
|
131
|
+
if (typeof cleanup === 'function') e.cleanup = cleanup
|
|
132
|
+
}
|
|
133
|
+
} while (dirty)
|
|
134
|
+
return tree
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { createElement, useState, useRef, useEffect, render, reset, unmount }
|
|
138
|
+
})()
|
|
139
|
+
|
|
140
|
+
// ── load the bundle through its ModuleLoader contract ──────────────────────
|
|
141
|
+
|
|
142
|
+
const primitivesStub = (() => {
|
|
143
|
+
const icon = (cls) => (props) => react.createElement('svg', Object.assign({ className: cls }, props))
|
|
144
|
+
return {
|
|
145
|
+
IconChevronDownOutline14: icon('ico-chev'),
|
|
146
|
+
IconCopyOutline16: icon('ico-copy'),
|
|
147
|
+
IconEditOutline16: icon('ico-edit'),
|
|
148
|
+
IconCloseOutline16: icon('ico-close'),
|
|
149
|
+
IconTrashOutline16: icon('ico-trash'),
|
|
150
|
+
IconPlayOutline16: icon('ico-run'),
|
|
151
|
+
// The harness's real toggle is a controlled `role=switch` button; the stub
|
|
152
|
+
// mirrors its contract so the gate exercises the primitive path, not just our
|
|
153
|
+
// fallback.
|
|
154
|
+
Switch: (props) => react.createElement('button', {
|
|
155
|
+
type: 'button',
|
|
156
|
+
role: 'switch',
|
|
157
|
+
'aria-checked': props.checked === true,
|
|
158
|
+
'aria-label': props.label,
|
|
159
|
+
disabled: props.disabled === true,
|
|
160
|
+
title: props.title,
|
|
161
|
+
className: 'dsh-switch-stub' + (props.className ? ' ' + props.className : ''),
|
|
162
|
+
onClick: () => { if (typeof props.onChange === 'function') props.onChange(props.checked !== true) },
|
|
163
|
+
}, react.createElement('span', { className: 'dsh-switch-thumb' })),
|
|
164
|
+
writeClipboard: () => Promise.resolve(true),
|
|
165
|
+
}
|
|
166
|
+
})()
|
|
167
|
+
|
|
168
|
+
let definition = null
|
|
169
|
+
const windowShim = {
|
|
170
|
+
__ModuleLoader__: {
|
|
171
|
+
load(def) {
|
|
172
|
+
definition = def
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
new Function('window', readFileSync(CLIENT, 'utf8'))(windowShim)
|
|
177
|
+
if (!definition || definition.id !== 'dsh-dbhub-live') throw new Error('bundle did not register through the ModuleLoader contract')
|
|
178
|
+
|
|
179
|
+
const req = (name) => {
|
|
180
|
+
if (name === 'react') return react
|
|
181
|
+
if (name === '@deepseek-ai/dsh-client-ui-primitives') return primitivesStub
|
|
182
|
+
throw new Error(`unexpected require: ${name}`)
|
|
183
|
+
}
|
|
184
|
+
const bundle = definition.factory(req)
|
|
185
|
+
|
|
186
|
+
// ── fake host (settings namespace + slots + locale) ────────────────────────
|
|
187
|
+
//
|
|
188
|
+
// The locale registry is a PAGE-level singleton in the real client (one
|
|
189
|
+
// namespace + locale can only be registered once until its owner disposes it),
|
|
190
|
+
// so it is modelled outside the mount: that is what makes a hot reload visible
|
|
191
|
+
// here as a duplicate-registration failure.
|
|
192
|
+
const LOCALE_STORE = new Map()
|
|
193
|
+
let liveMount = null
|
|
194
|
+
|
|
195
|
+
function mount(snapshot, opts = {}) {
|
|
196
|
+
const lang = opts.lang || 'zh'
|
|
197
|
+
const registrations = {}
|
|
198
|
+
const setCalls = []
|
|
199
|
+
const opCalls = []
|
|
200
|
+
const getCalls = []
|
|
201
|
+
const formSets = []
|
|
202
|
+
const effects = []
|
|
203
|
+
const disposeHandlers = []
|
|
204
|
+
const restores = []
|
|
205
|
+
// The legacy transport is a service the bundle PROBES (`ctx.get`), never one it
|
|
206
|
+
// injects, so the harness offers it the same way a dsh <= 0.1.6 client does.
|
|
207
|
+
const settingsScope = {
|
|
208
|
+
bind: () => ({
|
|
209
|
+
getSnapshot: () => ({ value: snapshot }),
|
|
210
|
+
subscribe: () => () => {},
|
|
211
|
+
set: (key, value) => {
|
|
212
|
+
if (key === 'configOp') opCalls.push(JSON.parse(String(value)))
|
|
213
|
+
else setCalls.push([key, value])
|
|
214
|
+
return Promise.resolve()
|
|
215
|
+
},
|
|
216
|
+
}),
|
|
217
|
+
}
|
|
218
|
+
// dsh 0.1.7+/0.2.x settings forms: `configForms.get(entryId).set(field, value)`.
|
|
219
|
+
const configForms = {
|
|
220
|
+
get: (entryId) => ({
|
|
221
|
+
set: (field, value) => {
|
|
222
|
+
formSets.push([entryId, field, value])
|
|
223
|
+
return Promise.resolve(true)
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
}
|
|
227
|
+
const ctx = {
|
|
228
|
+
get(name) {
|
|
229
|
+
if (name === 'settingsScope') return opts.transport === 'http' ? undefined : settingsScope
|
|
230
|
+
if (name === 'configForms') return opts.configForms ? configForms : undefined
|
|
231
|
+
if (name !== 'locale') return undefined
|
|
232
|
+
return {
|
|
233
|
+
getLocale: () => ({ active: lang }),
|
|
234
|
+
register(ns, entries) {
|
|
235
|
+
let owned = LOCALE_STORE.get(ns)
|
|
236
|
+
if (!owned) {
|
|
237
|
+
owned = new Map()
|
|
238
|
+
LOCALE_STORE.set(ns, owned)
|
|
239
|
+
}
|
|
240
|
+
for (const locale of Object.keys(entries)) {
|
|
241
|
+
if (owned.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
|
242
|
+
}
|
|
243
|
+
for (const [locale, dict] of Object.entries(entries)) owned.set(locale, dict)
|
|
244
|
+
return () => {
|
|
245
|
+
for (const [locale, dict] of Object.entries(entries)) if (owned.get(locale) === dict) owned.delete(locale)
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
bind(ns) {
|
|
249
|
+
return (key, params) => {
|
|
250
|
+
const owned = LOCALE_STORE.get(ns) || new Map()
|
|
251
|
+
const dict = owned.get(lang) || owned.get('zh') || {}
|
|
252
|
+
let text = Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : `!!${key}!!`
|
|
253
|
+
if (params) for (const k of Object.keys(params)) text = text.split(`{${k}}`).join(String(params[k]))
|
|
254
|
+
return text
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
effect(fn) {
|
|
260
|
+
effects.push(fn)
|
|
261
|
+
return () => {
|
|
262
|
+
const i = effects.indexOf(fn)
|
|
263
|
+
if (i >= 0) effects.splice(i, 1)
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
on(event, fn) {
|
|
267
|
+
if (event === 'dispose') disposeHandlers.push(fn)
|
|
268
|
+
return () => {}
|
|
269
|
+
},
|
|
270
|
+
slots: {
|
|
271
|
+
inject(name, cb) {
|
|
272
|
+
const out = cb()
|
|
273
|
+
if (!(name in registrations)) registrations[name] = out
|
|
274
|
+
return () => {}
|
|
275
|
+
},
|
|
276
|
+
register(def, comp) {
|
|
277
|
+
registrations[def.name] = { def, comp }
|
|
278
|
+
// A real slot registry REMOVES the entry when its disposer runs — the
|
|
279
|
+
// sidebar entry's whole point is that it disappears. Model that, or the
|
|
280
|
+
// "disable hides the entry" check could never fail.
|
|
281
|
+
return () => {
|
|
282
|
+
const current = registrations[def.name]
|
|
283
|
+
if (current && current.comp === comp) delete registrations[def.name]
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
if (opts.transport === 'http') {
|
|
289
|
+
// The bundle reads the page-global `fetch`; the fake answers every bridge
|
|
290
|
+
// route with the same view the legacy snapshot carries — but the view is
|
|
291
|
+
// MUTABLE, because an `enabled` write must be observable on the next read
|
|
292
|
+
// (that is exactly what re-syncs the sidebar entry).
|
|
293
|
+
const previousFetch = globalThis.fetch
|
|
294
|
+
const liveView = Object.assign({}, snapshot)
|
|
295
|
+
globalThis.fetch = async (input, init) => {
|
|
296
|
+
const body = init && typeof init.body === 'string' ? JSON.parse(init.body) : undefined
|
|
297
|
+
if (body && body.op !== undefined) opCalls.push(body.op)
|
|
298
|
+
else if (body && typeof body.enabled === 'boolean') {
|
|
299
|
+
setCalls.push(['enabled', body.enabled])
|
|
300
|
+
liveView.enabled = body.enabled
|
|
301
|
+
} else if (body) setCalls.push(['options', body])
|
|
302
|
+
else getCalls.push(String(input))
|
|
303
|
+
return { ok: true, status: 200, json: async () => ({ ok: true, value: liveView }) }
|
|
304
|
+
}
|
|
305
|
+
restores.push(() => { globalThis.fetch = previousFetch })
|
|
306
|
+
}
|
|
307
|
+
bundle.apply(ctx)
|
|
308
|
+
return {
|
|
309
|
+
registrations, setCalls, opCalls, getCalls, formSets,
|
|
310
|
+
/** What the plugin scope disposal (a hot reload) does. */
|
|
311
|
+
dispose() {
|
|
312
|
+
// Component effect cleanups first: the HTTP face stops polling in its
|
|
313
|
+
// subscription cleanup, and a leaked interval would hang this gate.
|
|
314
|
+
react.unmount()
|
|
315
|
+
const list = effects.slice().reverse()
|
|
316
|
+
effects.length = 0
|
|
317
|
+
for (const fn of list) {
|
|
318
|
+
const cleanup = fn()
|
|
319
|
+
if (typeof cleanup === 'function') cleanup()
|
|
320
|
+
}
|
|
321
|
+
const handlers = disposeHandlers.slice()
|
|
322
|
+
disposeHandlers.length = 0
|
|
323
|
+
for (const fn of handlers) fn()
|
|
324
|
+
for (const fn of restores.splice(0)) fn()
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── tree helpers ──────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
function toHtml(node) {
|
|
332
|
+
if (!node) return ''
|
|
333
|
+
if (node.kind === 'text') return node.text
|
|
334
|
+
if (node.kind === 'frag') return node.children.map(toHtml).join('')
|
|
335
|
+
const cls = node.props.className ? ` class="${node.props.className}"` : ''
|
|
336
|
+
const aria = node.props['aria-expanded'] === undefined ? '' : ` aria-expanded="${node.props['aria-expanded']}"`
|
|
337
|
+
const inner = node.children.map(toHtml).join('')
|
|
338
|
+
const tag = node.type === 'input' ? 'input' : node.type === 'svg' ? 'svg' : node.type === 'button' ? 'button' : 'div'
|
|
339
|
+
return `<${tag}${cls}${aria}>${inner}</${tag}>`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function count(node, cls) {
|
|
343
|
+
if (!node || node.kind === 'text') return 0
|
|
344
|
+
let n = node.kind === 'el' && String(node.props.className || '').split(/\s+/).includes(cls) ? 1 : 0
|
|
345
|
+
for (const c of node.children || []) n += count(c, cls)
|
|
346
|
+
return n
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function elements(node, pred, out = []) {
|
|
350
|
+
if (!node || node.kind === 'text') return out
|
|
351
|
+
if (node.kind === 'el' && pred(node)) out.push(node)
|
|
352
|
+
for (const c of node.children || []) elements(c, pred, out)
|
|
353
|
+
return out
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const classed = (node, cls) => elements(node, (el) => String(el.props.className || '').split(/\s+/).includes(cls))
|
|
357
|
+
|
|
358
|
+
/** Click the button whose aria-label is exactly `label` (icons carry no text). */
|
|
359
|
+
function clickLabel(node, label) {
|
|
360
|
+
clickLabelAt(node, label, 0)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Click the nth button whose aria-label is exactly `label`. */
|
|
364
|
+
function clickLabelAt(node, label, index) {
|
|
365
|
+
const hits = elements(node, (el) => el.props['aria-label'] === label && typeof el.props.onClick === 'function')
|
|
366
|
+
const hit = hits[index]
|
|
367
|
+
if (!hit) throw new Error(`no clickable element labelled ${JSON.stringify(label)} at index ${index} (found ${hits.length})`)
|
|
368
|
+
hit.props.onClick({ stopPropagation() {}, preventDefault() {} })
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function clickWithin(node, cls, text) {
|
|
372
|
+
for (const scope of classed(node, cls)) {
|
|
373
|
+
const hit = elements(scope, (el) => typeof el.props.onClick === 'function'
|
|
374
|
+
&& (el.children || []).map(toHtml).join('').includes(text))[0]
|
|
375
|
+
if (hit) {
|
|
376
|
+
hit.props.onClick({ stopPropagation() {}, preventDefault() {} })
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
throw new Error(`no clickable ${JSON.stringify(text)} inside .${cls}`)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Type into the nth `.dbh-input` inside `.cls` of the tree, as a browser would. */
|
|
384
|
+
function typeWithin(node, cls, index, value) {
|
|
385
|
+
const scope = classed(node, cls)[0]
|
|
386
|
+
if (!scope) throw new Error(`no .${cls} in the tree`)
|
|
387
|
+
const input = classed(scope, 'dbh-input')[index]
|
|
388
|
+
if (!input || typeof input.props.onChange !== 'function') throw new Error(`no input ${index} inside .${cls}`)
|
|
389
|
+
input.props.onChange({ target: { value } })
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function click(node, text) {
|
|
393
|
+
const hit = elements(node, (el) => {
|
|
394
|
+
const inner = (el.children || []).map(toHtml).join('')
|
|
395
|
+
return typeof el.props.onClick === 'function' && inner.includes(text)
|
|
396
|
+
})[0]
|
|
397
|
+
if (!hit) throw new Error(`no clickable element containing ${JSON.stringify(text)}`)
|
|
398
|
+
hit.props.onClick({ stopPropagation() {}, preventDefault() {} })
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function clickClass(node, cls) {
|
|
402
|
+
const hit = classed(node, cls)[0]
|
|
403
|
+
if (!hit || typeof hit.props.onClick !== 'function') throw new Error(`no clickable element with class ${cls}`)
|
|
404
|
+
hit.props.onClick({ stopPropagation() {}, preventDefault() {} })
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The nth checkbox/radio (class `dbh-checkbox`) inside the first `.cls` scope. */
|
|
408
|
+
function checkBoxWithin(node, cls, index, checked) {
|
|
409
|
+
const scope = classed(node, cls)[0]
|
|
410
|
+
if (!scope) throw new Error(`no .${cls} in the tree`)
|
|
411
|
+
const boxes = elements(scope, (el) => el.type === 'input'
|
|
412
|
+
&& String(el.props.className || '').split(/\s+/).includes('dbh-checkbox')
|
|
413
|
+
&& typeof el.props.onChange === 'function')
|
|
414
|
+
const box = boxes[index]
|
|
415
|
+
if (!box) throw new Error(`no checkbox ${index} inside .${cls} (found ${boxes.length})`)
|
|
416
|
+
box.props.onChange({ target: { checked } })
|
|
417
|
+
return box
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── scenario data ─────────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
const row = (title, path, env, conn, extra = {}) => Object.assign({
|
|
423
|
+
title, path, env, conn, source: 'persisted(user)', persisted: true,
|
|
424
|
+
srcId: `${title}_1a2b3c${env === 'default' ? '' : '_' + env}`,
|
|
425
|
+
}, extra)
|
|
426
|
+
|
|
427
|
+
const snapshotOf = (rows, extra = {}) => Object.assign({
|
|
428
|
+
enabled: true, phase: 'running', toolCount: 4, lastError: '', mode: 'oneshot',
|
|
429
|
+
updateIntervalDays: 7, testResult: '',
|
|
430
|
+
// Upstream capability probe (JSON string). '' = "not probed yet", which the
|
|
431
|
+
// page treats as "assume supported".
|
|
432
|
+
capabilities: '',
|
|
433
|
+
workspaces: JSON.stringify(rows),
|
|
434
|
+
}, extra)
|
|
435
|
+
|
|
436
|
+
const TILED = [
|
|
437
|
+
row('app', 'D:/work/app', 'default', 'mysql://127.0.0.1:3306/app'),
|
|
438
|
+
// a long handle, so the shortened form is exercised
|
|
439
|
+
row('dsh-plugin', 'D:/work/plugin', 'default', 'postgres://127.0.0.1:5432/plug'),
|
|
440
|
+
row('docs', 'D:/work/docs', 'default', 'sqlite://127.0.0.1:0/docs', { source: '工作目录 .env', persisted: false }),
|
|
441
|
+
]
|
|
442
|
+
const GROUPED = [
|
|
443
|
+
row('app', 'D:/work/app', 'default', 'mysql://127.0.0.1:3306/app'),
|
|
444
|
+
row('app', 'D:/work/app', 'prod', 'mysql://127.0.0.1:3307/app'),
|
|
445
|
+
row('app', 'D:/work/app', '线上', 'mysql://127.0.0.1:3308/app', { persisted: false, source: '工作区 mise env' }),
|
|
446
|
+
row('plugin', 'D:/work/plugin', 'default', 'postgres://127.0.0.1:5432/plug'),
|
|
447
|
+
]
|
|
448
|
+
const SINGLE_MULTI = [
|
|
449
|
+
row('app', 'D:/work/app', 'default', 'mysql://127.0.0.1:3306/app'),
|
|
450
|
+
row('app', 'D:/work/app', 'prod', 'mysql://127.0.0.1:3307/app'),
|
|
451
|
+
]
|
|
452
|
+
const DUP_TITLES = [
|
|
453
|
+
row('app', 'D:/work/one/app', 'default', 'mysql://127.0.0.1:3306/a'),
|
|
454
|
+
row('app', 'D:/work/two/app', 'default', 'mysql://127.0.0.1:3306/b'),
|
|
455
|
+
]
|
|
456
|
+
const wsSnap = (path, updatedAt = '2026-01-01T00:00:00Z') => (sel) => sel({ items: [{ path, updatedAt }] })
|
|
457
|
+
|
|
458
|
+
// ── assertion helpers ─────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
let failures = 0
|
|
461
|
+
function check(label, condition, detail) {
|
|
462
|
+
if (condition) console.log(` ok ${label}`)
|
|
463
|
+
else {
|
|
464
|
+
failures++
|
|
465
|
+
console.log(` FAIL ${label}${detail ? ` — ${detail}` : ''}`)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Mount the settings-section page component with the REAL injected face. */
|
|
470
|
+
function page(rows, opts = {}) {
|
|
471
|
+
// A hot reload disposes the previous instance before applying the new bundle;
|
|
472
|
+
// `keepStale` models the pathological case where it could not.
|
|
473
|
+
if (liveMount && !opts.keepStale) liveMount.dispose()
|
|
474
|
+
const snapshot = snapshotOf(rows, opts.snapshot)
|
|
475
|
+
const m = mount(snapshot, opts)
|
|
476
|
+
liveMount = m
|
|
477
|
+
const slot = opts.slot || 'settings.section'
|
|
478
|
+
const entry = m.registrations[slot]
|
|
479
|
+
if (!entry) throw new Error(`${slot} was not registered`)
|
|
480
|
+
const props = Object.assign({}, entry.def.inject(), opts.props, { view: 'page' })
|
|
481
|
+
const comp = entry.comp
|
|
482
|
+
const renderAgain = () => react.render(comp, props)
|
|
483
|
+
react.reset()
|
|
484
|
+
return Object.assign(m, { props, comp, renderAgain, tree: react.render(comp, props) })
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** The browser viewport width the page sees (the bundle falls back to it when
|
|
488
|
+
* ResizeObserver is unavailable — which is this offscreen harness). */
|
|
489
|
+
function viewport(px) {
|
|
490
|
+
windowShim.innerWidth = px
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** A modern (dsh >= 0.2.1) host view: version fields + an empty update snapshot. */
|
|
494
|
+
const MODERN_DEFAULTS = {
|
|
495
|
+
dshModern: true,
|
|
496
|
+
pluginVersion: '5.2.0-dev.1',
|
|
497
|
+
dshVersion: '0.2.1-rc.2',
|
|
498
|
+
runtime: 'web',
|
|
499
|
+
update: '',
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Set a `.dbh-switch-input` (the modern 显示/隐藏 toggle lives in it). */
|
|
503
|
+
function switchWithin(node, cls, index, checked) {
|
|
504
|
+
const scope = classed(node, cls)[0]
|
|
505
|
+
if (!scope) throw new Error(`no .${cls} in the tree`)
|
|
506
|
+
const inputs = elements(scope, (el) => el.type === 'input'
|
|
507
|
+
&& String(el.props.className || '').split(/\s+/).includes('dbh-switch-input'))
|
|
508
|
+
const input = inputs[index]
|
|
509
|
+
if (!input) throw new Error(`no switch ${index} inside .${cls} (found ${inputs.length})`)
|
|
510
|
+
input.props.onChange({ target: { checked } })
|
|
511
|
+
return input
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Click the nth `role=switch` control inside the first `.cls` scope (the
|
|
515
|
+
* harness Switch primitive's contract: click asks for the opposite state). */
|
|
516
|
+
function clickSwitchWithin(node, cls, index) {
|
|
517
|
+
const scope = classed(node, cls)[0]
|
|
518
|
+
if (!scope) throw new Error(`no .${cls} in the tree`)
|
|
519
|
+
const switches = elements(scope, (el) => el.type === 'button' && el.props.role === 'switch')
|
|
520
|
+
const sw = switches[index]
|
|
521
|
+
if (!sw) throw new Error(`no role=switch ${index} inside .${cls} (found ${switches.length})`)
|
|
522
|
+
sw.props.onClick({ stopPropagation() {}, preventDefault() {} })
|
|
523
|
+
return sw
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** All `role=switch` buttons in the tree. */
|
|
527
|
+
function switches(node) {
|
|
528
|
+
return elements(node, (el) => el.type === 'button' && el.props.role === 'switch')
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
console.log('\n[1] tiled layout (every workspace has exactly one environment)')
|
|
532
|
+
{
|
|
533
|
+
const p = page(TILED, { props: { useWorkspaces: wsSnap('D:/work/app') } })
|
|
534
|
+
const html = toHtml(p.tree)
|
|
535
|
+
check('tile grid rendered', count(p.tree, 'dbh-tiles') === 1)
|
|
536
|
+
check('one tile per workspace', count(p.tree, 'dbh-tile') === 3, `got ${count(p.tree, 'dbh-tile')}`)
|
|
537
|
+
check('no accordion groups', count(p.tree, 'dbh-group') === 0)
|
|
538
|
+
check('no expand-all control', !html.includes('全部展开') && !html.includes('全部折叠'))
|
|
539
|
+
check('one env chip per tile', count(p.tree, 'dbh-envname') === 3, `got ${count(p.tree, 'dbh-envname')}`)
|
|
540
|
+
check('connection metadata shown once per tile', count(p.tree, 'dbh-conn') === 3)
|
|
541
|
+
check('no inline source handle line', !html.includes('source:') && !html.includes('dsh-plugin_1a2b3c'))
|
|
542
|
+
check('one source chip per connection', classed(p.tree, 'dbh-src').length === 3, `got ${classed(p.tree, 'dbh-src').length}`)
|
|
543
|
+
// auto-discovered rows have no delete action, so 3 + 3 + 2 icon buttons
|
|
544
|
+
check('actions are icon-only', classed(p.tree, 'dbh-iconbtn').length === 8, `got ${classed(p.tree, 'dbh-iconbtn').length}`)
|
|
545
|
+
const handles = classed(p.tree, 'dbh-srcid').map((el) => toHtml(el))
|
|
546
|
+
check('short handles are shown whole', handles.some((h) => h.includes('app_1a2b3c')) && handles.some((h) => h.includes('docs_1a2b3c')), JSON.stringify(handles))
|
|
547
|
+
check('long handles keep their distinguishing tail', handles.some((h) => h.includes('…1a2b3c')), JSON.stringify(handles))
|
|
548
|
+
check('database name rendered separately', classed(p.tree, 'dbh-conn-db').length === 3)
|
|
549
|
+
check('host/port prefix is the truncatable half', classed(p.tree, 'dbh-conn-pre').length === 3)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
console.log('\n[2] grouped layout (one workspace owns several environments)')
|
|
553
|
+
{
|
|
554
|
+
const p = page(GROUPED, { props: { useWorkspaces: wsSnap('D:/work/app', '2026-01-02T00:00:00Z') } })
|
|
555
|
+
if (process.env.DBHUB_DEBUG) console.log(toHtml(p.tree).slice(0, 3000))
|
|
556
|
+
const heads = classed(p.tree, 'dbh-grouphead')
|
|
557
|
+
const html = toHtml(p.tree)
|
|
558
|
+
check('group per workspace', count(p.tree, 'dbh-group') === 2, `got ${count(p.tree, 'dbh-group')}`)
|
|
559
|
+
check('recently used workspace auto-opens', heads[0].props['aria-expanded'] === true)
|
|
560
|
+
check('the other workspace stays folded', heads[1].props['aria-expanded'] === false)
|
|
561
|
+
check('folded group hides its rows', count(p.tree, 'dbh-envrow') === 3, `got ${count(p.tree, 'dbh-envrow')}`)
|
|
562
|
+
check('expand-all control present', html.includes('全部展开'))
|
|
563
|
+
check('per-group add button present', html.includes('+ 环境'))
|
|
564
|
+
check('group head shows env/saved counts', html.includes('3 环境 · 2 已保存'))
|
|
565
|
+
check('emoji/status line present', html.includes('🟢 运行中'))
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
console.log('\n[3] a lone workspace opens by itself (no workspace snapshot available)')
|
|
569
|
+
{
|
|
570
|
+
const p = page(SINGLE_MULTI, {})
|
|
571
|
+
const heads = classed(p.tree, 'dbh-grouphead')
|
|
572
|
+
check('single group', count(p.tree, 'dbh-group') === 1)
|
|
573
|
+
check('open by default', heads[0].props['aria-expanded'] === true)
|
|
574
|
+
check('its rows are visible', count(p.tree, 'dbh-envrow') === 2)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
console.log('\n[4] empty state')
|
|
578
|
+
{
|
|
579
|
+
const p = page([], {})
|
|
580
|
+
const html = toHtml(p.tree)
|
|
581
|
+
check('empty hint rendered', count(p.tree, 'dbh-empty') === 1)
|
|
582
|
+
check('add entry still offered', html.includes('+ 添加连接'))
|
|
583
|
+
check('no tiles / groups', count(p.tree, 'dbh-tile') === 0 && count(p.tree, 'dbh-group') === 0)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
console.log('\n[5] duplicate workspace titles are disambiguated by path')
|
|
587
|
+
{
|
|
588
|
+
const p = page(DUP_TITLES, {})
|
|
589
|
+
const html = toHtml(p.tree)
|
|
590
|
+
check('both paths shown', html.includes('D:/work/one/app') && html.includes('D:/work/two/app'))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
console.log('\n[6] malformed workspace snapshot cannot break the page')
|
|
594
|
+
{
|
|
595
|
+
const p = page(GROUPED, { props: { useWorkspaces: () => null } })
|
|
596
|
+
check('falls back to all-folded', classed(p.tree, 'dbh-grouphead').every((el) => el.props['aria-expanded'] === false))
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
console.log('\n[7] interaction: expand/collapse all + group toggle')
|
|
600
|
+
{
|
|
601
|
+
const p = page(GROUPED, {})
|
|
602
|
+
check('starts folded without a snapshot', count(p.tree, 'dbh-envrow') === 0)
|
|
603
|
+
click(p.tree, '全部展开')
|
|
604
|
+
p.tree = p.renderAgain()
|
|
605
|
+
check('expand-all opens every group', count(p.tree, 'dbh-envrow') === 4, `got ${count(p.tree, 'dbh-envrow')}`)
|
|
606
|
+
click(p.tree, '全部折叠')
|
|
607
|
+
p.tree = p.renderAgain()
|
|
608
|
+
check('collapse-all folds every group', count(p.tree, 'dbh-envrow') === 0)
|
|
609
|
+
click(p.tree, 'app')
|
|
610
|
+
p.tree = p.renderAgain()
|
|
611
|
+
check('clicking a group head opens it', count(p.tree, 'dbh-envrow') === 3, `got ${count(p.tree, 'dbh-envrow')}`)
|
|
612
|
+
const heads = classed(p.tree, 'dbh-grouphead')
|
|
613
|
+
check('aria-expanded tracks the group', heads[0].props['aria-expanded'] === true && heads[1].props['aria-expanded'] === false)
|
|
614
|
+
click(p.tree, 'app')
|
|
615
|
+
p.tree = p.renderAgain()
|
|
616
|
+
check('clicking it again folds it', count(p.tree, 'dbh-envrow') === 0)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
console.log('\n[8] interaction: add panel (global + per group), settings strip, copy')
|
|
620
|
+
{
|
|
621
|
+
const p = page(GROUPED, {})
|
|
622
|
+
check('add panel hidden by default', count(p.tree, 'dbh-addpanel') === 0)
|
|
623
|
+
check('settings strip hidden by default', count(p.tree, 'dbh-strip') === 0)
|
|
624
|
+
check('collapsed summary line shown', toHtml(p.tree).includes('自动更新 7 天'))
|
|
625
|
+
click(p.tree, '+ 添加连接')
|
|
626
|
+
p.tree = p.renderAgain()
|
|
627
|
+
check('global add panel opens with 3 fields', count(p.tree, 'dbh-addpanel') === 1 && classed(p.tree, 'dbh-field').length === 3)
|
|
628
|
+
const fields = classed(p.tree, 'dbh-field')
|
|
629
|
+
check('fields are workspace / env / DSN', toHtml(fields[0]).includes('工作区') && toHtml(fields[2]).includes('DSN'))
|
|
630
|
+
const datalists = elements(p.tree, (el) => el.type === 'datalist')
|
|
631
|
+
check('workspace datalist fed from existing groups', datalists.length === 1 && elements(datalists[0], (el) => el.type === 'option').length === 2)
|
|
632
|
+
click(p.tree, '取消')
|
|
633
|
+
p.tree = p.renderAgain()
|
|
634
|
+
check('cancel closes the add panel', count(p.tree, 'dbh-addpanel') === 0)
|
|
635
|
+
click(p.tree, '+ 环境')
|
|
636
|
+
p.tree = p.renderAgain()
|
|
637
|
+
const target = classed(p.tree, 'dbh-target')[0]
|
|
638
|
+
check('per-group add pre-fills the workspace', !!target && toHtml(target).includes('D:/work/app'))
|
|
639
|
+
check('per-group add drops the workspace input', classed(p.tree, 'dbh-field').length === 3 && classed(p.tree, 'dbh-target').length === 1)
|
|
640
|
+
click(p.tree, '取消')
|
|
641
|
+
p.tree = p.renderAgain()
|
|
642
|
+
click(p.tree, 'app')
|
|
643
|
+
p.tree = p.renderAgain()
|
|
644
|
+
check('group expanded for the copy check', count(p.tree, 'dbh-envrow') === 3)
|
|
645
|
+
clickClass(p.tree, 'dbh-src')
|
|
646
|
+
// writeClipboard resolves a promise, so the copied flag lands a microtask later
|
|
647
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
648
|
+
p.tree = p.renderAgain()
|
|
649
|
+
check('copy chip works without navigator.clipboard', classed(p.tree, 'dbh-src').length === 3)
|
|
650
|
+
check('copy feedback shown', toHtml(p.tree).includes('已复制'))
|
|
651
|
+
click(p.tree, '设置')
|
|
652
|
+
p.tree = p.renderAgain()
|
|
653
|
+
check('settings strip opens', count(p.tree, 'dbh-strip') === 1)
|
|
654
|
+
// The sidebar switch is a USER preference again (dev.3 correction): hiding the
|
|
655
|
+
// shortcut is not the same as disabling the plugin, so the strip offers it
|
|
656
|
+
// next to the interval.
|
|
657
|
+
check('strip carries the sidebar switch again', toHtml(p.tree).includes('在侧边栏显示入口'))
|
|
658
|
+
check('strip keeps the interval field', classed(p.tree, 'dbh-num').length === 1)
|
|
659
|
+
check('strip explains that hiding the entry is not disabling the plugin',
|
|
660
|
+
count(p.tree, 'dbh-hint') >= 1 && toHtml(classed(p.tree, 'dbh-strip')[0]).includes('侧边栏'))
|
|
661
|
+
// Toggling it applies immediately (no Save round-trip) and the summary follows.
|
|
662
|
+
const beforeWrites = p.setCalls.length
|
|
663
|
+
checkBoxWithin(p.tree, 'dbh-strip', 0, false)
|
|
664
|
+
p.tree = p.renderAgain()
|
|
665
|
+
// saveConfig queues its writes, so let the microtasks drain before asserting.
|
|
666
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
667
|
+
const writes = p.setCalls.slice(beforeWrites)
|
|
668
|
+
check('sidebar switch writes showSidebarEntry immediately',
|
|
669
|
+
writes.some(([k, v]) => k === 'showSidebarEntry' && v === false), JSON.stringify(writes))
|
|
670
|
+
check('the settings summary reflects the switch', toHtml(p.tree).includes('侧边栏入口:隐藏'))
|
|
671
|
+
|
|
672
|
+
// The policy announcement is a SECOND, independent switch: it changes what
|
|
673
|
+
// the model sees (one system-prompt section), never what the tools can do.
|
|
674
|
+
check('strip carries the policy announcement switch', toHtml(p.tree).includes('向模型注入数据库访问策略'))
|
|
675
|
+
const beforePolicy = p.setCalls.length
|
|
676
|
+
checkBoxWithin(p.tree, 'dbh-strip', 1, false)
|
|
677
|
+
p.tree = p.renderAgain()
|
|
678
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
679
|
+
const policyWrites = p.setCalls.slice(beforePolicy)
|
|
680
|
+
check('policy switch writes announceAccessPolicy immediately',
|
|
681
|
+
policyWrites.some(([k, v]) => k === 'announceAccessPolicy' && v === false), JSON.stringify(policyWrites))
|
|
682
|
+
check('disabling the announcement does not touch the enabled state',
|
|
683
|
+
!policyWrites.some(([k]) => k === 'enabled'), JSON.stringify(policyWrites))
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
console.log('\n[9] summary view (Plugins page one-liner)')
|
|
687
|
+
{
|
|
688
|
+
const p = page(TILED, {})
|
|
689
|
+
const summaryReg = p.registrations['plugins.row.config']
|
|
690
|
+
const summaryProps = Object.assign({}, summaryReg.def.inject(), { view: 'summary' })
|
|
691
|
+
const tree = react.render(summaryReg.comp, summaryProps)
|
|
692
|
+
const html = toHtml(tree)
|
|
693
|
+
check('single line', count(tree, 'dbh-summary') === 1 && count(tree, 'dbh-tile') === 0)
|
|
694
|
+
check('carries phase, tool count and env count', html.includes('运行中') && html.includes('4 个工具') && html.includes('3 个环境'))
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
console.log('\n[10] i18n coverage (no raw key or unfilled placeholder may reach the page)')
|
|
698
|
+
{
|
|
699
|
+
const problems = []
|
|
700
|
+
for (const lang of ['zh', 'en']) {
|
|
701
|
+
for (const rows of [GROUPED, TILED, []]) {
|
|
702
|
+
const p = page(rows, { lang, props: { useWorkspaces: wsSnap('D:/work/app') } })
|
|
703
|
+
const html = toHtml(p.tree)
|
|
704
|
+
const leftovers = html.match(/!![a-zA-Z][a-zA-Z.]*!!/g) || []
|
|
705
|
+
const placeholders = html.match(/\{[a-zA-Z]\}/g) || []
|
|
706
|
+
if (leftovers.length) problems.push(`${lang}/${rows.length}rows missing: ${[...new Set(leftovers)].join(', ')}`)
|
|
707
|
+
if (placeholders.length) problems.push(`${lang}/${rows.length}rows unfilled: ${[...new Set(placeholders)].join(', ')}`)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
check('every key exists in both dictionaries and every placeholder is filled', problems.length === 0, problems.join(' | '))
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
console.log('\n[11] disable/enable + option writes still go through the namespace')
|
|
714
|
+
{
|
|
715
|
+
const p = page(TILED, {})
|
|
716
|
+
click(p.tree, '禁用')
|
|
717
|
+
check('setEnabled(false) dispatched', p.setCalls.some(([k, v]) => k === 'enabled' && v === false), JSON.stringify(p.setCalls))
|
|
718
|
+
if (count(p.tree, 'dbh-strip') === 0) {
|
|
719
|
+
click(p.tree, '设置')
|
|
720
|
+
p.tree = p.renderAgain()
|
|
721
|
+
}
|
|
722
|
+
check('settings strip open', count(p.tree, 'dbh-strip') === 1)
|
|
723
|
+
click(p.tree, '保存配置')
|
|
724
|
+
// saveConfig queues one path-op write per field, so let the microtasks drain.
|
|
725
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
726
|
+
check('saveConfig writes updateIntervalDays', p.setCalls.some(([k, v]) => k === 'updateIntervalDays' && v === 7), JSON.stringify(p.setCalls))
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
console.log('\n[12] view state survives a remount inside the same page session')
|
|
730
|
+
{
|
|
731
|
+
const first = page(SINGLE_MULTI, {})
|
|
732
|
+
click(first.tree, '全部折叠')
|
|
733
|
+
first.tree = first.renderAgain()
|
|
734
|
+
check('folded in the first mount', count(first.tree, 'dbh-envrow') === 0)
|
|
735
|
+
const second = page(SINGLE_MULTI, {})
|
|
736
|
+
check('a new mount keeps the folded choice', count(second.tree, 'dbh-envrow') === 0)
|
|
737
|
+
check('and keeps the settings strip open', count(second.tree, 'dbh-strip') === 1)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
console.log('\n[13] connection origin is a tooltip, not an inline badge')
|
|
741
|
+
{
|
|
742
|
+
// A dedicated fixture: another workspace path cannot inherit an expansion
|
|
743
|
+
// choice remembered from an earlier mount (view state is shared per session).
|
|
744
|
+
const rows = [
|
|
745
|
+
row('origin-app', 'D:/work/origin', 'default', 'mysql://127.0.0.1:3306/a'),
|
|
746
|
+
row('origin-app', 'D:/work/origin', 'staging', 'mysql://127.0.0.1:3307/a', { persisted: false, source: '工作区 mise env' }),
|
|
747
|
+
]
|
|
748
|
+
const p = page(rows, {})
|
|
749
|
+
const titles = classed(p.tree, 'dbh-envname').map((c) => String(c.props.title || ''))
|
|
750
|
+
check('both environment chips rendered', titles.length === 2, JSON.stringify(titles))
|
|
751
|
+
check('no pill / badge element anywhere', classed(p.tree, 'dbh-pill').length === 0)
|
|
752
|
+
check('hand-entered row names its origin only in the tooltip', titles.some((x) => x.includes('来源:已保存·用户')))
|
|
753
|
+
check('auto-discovered row says it is not saved', titles.some((x) => x.includes('来源:自动·mise env') && x.includes('未保存')))
|
|
754
|
+
const tiled = page(TILED, { props: { useWorkspaces: wsSnap('D:/work/origin') } })
|
|
755
|
+
check('tiled mode uses the same tooltip', classed(tiled.tree, 'dbh-envname').some((c) => String(c.props.title || '').includes('来源:')))
|
|
756
|
+
check('tiled mode has no badge either', classed(tiled.tree, 'dbh-pill').length === 0)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
console.log('\n[14] a hot reload must not break on duplicate locale registration')
|
|
760
|
+
{
|
|
761
|
+
// The live failure this guards: "locale namespace dsh-dbhub-live already has
|
|
762
|
+
// locale zh" after a client bundle swap, which aborts the whole plugin half.
|
|
763
|
+
const ns = 'dsh-dbhub-live'
|
|
764
|
+
const first = page(TILED, {})
|
|
765
|
+
check('apply owns the namespace dictionaries', (LOCALE_STORE.get(ns) || new Map()).size === 2)
|
|
766
|
+
first.dispose()
|
|
767
|
+
check('dispose releases them for the next instance', (LOCALE_STORE.get(ns) || new Map()).size === 0)
|
|
768
|
+
const second = page(TILED, {})
|
|
769
|
+
check('a clean reload re-registers', (LOCALE_STORE.get(ns) || new Map()).size === 2)
|
|
770
|
+
check('reloaded page renders localized copy', toHtml(second.tree).includes('运行中'))
|
|
771
|
+
// pathological: the previous instance never released its dictionaries
|
|
772
|
+
let failure = null
|
|
773
|
+
let stale = null
|
|
774
|
+
try {
|
|
775
|
+
stale = page(TILED, { keepStale: true })
|
|
776
|
+
} catch (e) {
|
|
777
|
+
failure = e
|
|
778
|
+
}
|
|
779
|
+
check('duplicate registration does not fail the plugin half', failure === null, failure && failure.message)
|
|
780
|
+
const html = stale ? toHtml(stale.tree) : ''
|
|
781
|
+
check('the page still renders its own copy (no raw keys)', html.includes('运行中') && !html.includes('ws.source'))
|
|
782
|
+
check('including the newly added keys', stale !== null && classed(stale.tree, 'dbh-envname').some((c) => String(c.props.title || '').includes('来源')))
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
console.log('\n[15] writing into an existing environment asks first')
|
|
786
|
+
{
|
|
787
|
+
// The reported flaw: an add whose environment name already exists silently
|
|
788
|
+
// replaced that connection (the Host's setWorkspaceEnv always overwrites).
|
|
789
|
+
// A dedicated fixture: a lone workspace group opens by itself, so the scenario
|
|
790
|
+
// does not depend on expansion state remembered by earlier mounts.
|
|
791
|
+
const OVERWRITE_ROWS = [
|
|
792
|
+
row('ovr-app', 'D:/work/ovr', 'default', 'mysql://127.0.0.1:3306/app'),
|
|
793
|
+
row('ovr-app', 'D:/work/ovr', 'prod', 'mysql://127.0.0.1:3307/app'),
|
|
794
|
+
]
|
|
795
|
+
const p = page(OVERWRITE_ROWS, {})
|
|
796
|
+
check('the lone workspace is open', count(p.tree, 'dbh-envrow') === 2)
|
|
797
|
+
click(p.tree, '+ 环境')
|
|
798
|
+
p.tree = p.renderAgain()
|
|
799
|
+
check('per-group add has no workspace field', classed(p.tree, 'dbh-target').length === 1)
|
|
800
|
+
// env input is index 0 (the per-group form has no workspace input)
|
|
801
|
+
typeWithin(p.tree, 'dbh-addpanel', 0, 'prod')
|
|
802
|
+
p.tree = p.renderAgain()
|
|
803
|
+
typeWithin(p.tree, 'dbh-addpanel', 1, 'mysql://127.0.0.1:3399/app')
|
|
804
|
+
p.tree = p.renderAgain()
|
|
805
|
+
clickWithin(p.tree, 'dbh-addpanel', '添加连接')
|
|
806
|
+
p.tree = p.renderAgain()
|
|
807
|
+
check('collision shows the overwrite prompt', count(p.tree, 'dbh-warncard') === 1)
|
|
808
|
+
check('the prompt names the existing connection', toHtml(p.tree).includes('127.0.0.1:3307'))
|
|
809
|
+
check('nothing was dispatched before the answer', p.opCalls.length === 0, JSON.stringify(p.opCalls))
|
|
810
|
+
check('the pre-test card is not shown at the same time', count(p.tree, 'dbh-pretest') === 0)
|
|
811
|
+
clickWithin(p.tree, 'dbh-warncard', '取消')
|
|
812
|
+
p.tree = p.renderAgain()
|
|
813
|
+
check('cancel dismisses the prompt', count(p.tree, 'dbh-warncard') === 0)
|
|
814
|
+
check('cancel writes nothing at all', p.opCalls.length === 0, JSON.stringify(p.opCalls))
|
|
815
|
+
check('the form survives so the name can be fixed', classed(p.tree, 'dbh-addpanel').length === 1)
|
|
816
|
+
// same click, but confirmed
|
|
817
|
+
clickWithin(p.tree, 'dbh-addpanel', '添加连接')
|
|
818
|
+
p.tree = p.renderAgain()
|
|
819
|
+
clickWithin(p.tree, 'dbh-warncard', '覆盖并保存')
|
|
820
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
821
|
+
check('confirm enters the normal pre-test flow exactly once',
|
|
822
|
+
p.opCalls.length === 1 && p.opCalls[0].op === 'test' && p.opCalls[0].env === 'prod' && p.opCalls[0].workspace === 'D:/work/ovr',
|
|
823
|
+
JSON.stringify(p.opCalls))
|
|
824
|
+
|
|
825
|
+
// a free environment name must NOT be questioned
|
|
826
|
+
const free = page(OVERWRITE_ROWS, {})
|
|
827
|
+
click(free.tree, '+ 添加连接')
|
|
828
|
+
free.tree = free.renderAgain()
|
|
829
|
+
typeWithin(free.tree, 'dbh-addpanel', 1, 'staging')
|
|
830
|
+
free.tree = free.renderAgain()
|
|
831
|
+
typeWithin(free.tree, 'dbh-addpanel', 2, 'mysql://127.0.0.1:3399/app')
|
|
832
|
+
free.tree = free.renderAgain()
|
|
833
|
+
clickWithin(free.tree, 'dbh-addpanel', '添加连接')
|
|
834
|
+
free.tree = free.renderAgain()
|
|
835
|
+
check('a free environment name is not questioned', count(free.tree, 'dbh-warncard') === 0)
|
|
836
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
837
|
+
check('and goes straight to the pre-test', free.opCalls.some((o) => o.op === 'test' && o.env === 'staging'), JSON.stringify(free.opCalls))
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
console.log('\n[16] renaming onto an existing environment asks first too')
|
|
841
|
+
{
|
|
842
|
+
const RENAME_ROWS = [
|
|
843
|
+
row('rn-app', 'D:/work/rn', 'default', 'mysql://127.0.0.1:3306/app'),
|
|
844
|
+
row('rn-app', 'D:/work/rn', 'prod', 'mysql://127.0.0.1:3307/app'),
|
|
845
|
+
]
|
|
846
|
+
const p = page(RENAME_ROWS, {})
|
|
847
|
+
clickLabel(p.tree, '修改') // first row = rn-app/default
|
|
848
|
+
p.tree = p.renderAgain()
|
|
849
|
+
typeWithin(p.tree, 'dbh-editrow', 0, 'prod') // rename default → prod (exists, no DSN change)
|
|
850
|
+
p.tree = p.renderAgain()
|
|
851
|
+
clickWithin(p.tree, 'dbh-editrow', '保存')
|
|
852
|
+
p.tree = p.renderAgain()
|
|
853
|
+
check('rename onto an existing environment asks first', count(p.tree, 'dbh-warncard') === 1)
|
|
854
|
+
check('no rename dispatched yet', p.opCalls.length === 0, JSON.stringify(p.opCalls))
|
|
855
|
+
clickWithin(p.tree, 'dbh-warncard', '覆盖并保存')
|
|
856
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
857
|
+
check('confirmed rename is dispatched once with the right names',
|
|
858
|
+
p.opCalls.length === 1 && p.opCalls[0].op === 'rename' && p.opCalls[0].env === 'default' && p.opCalls[0].newEnv === 'prod',
|
|
859
|
+
JSON.stringify(p.opCalls))
|
|
860
|
+
|
|
861
|
+
// editing the connection of the SAME environment is not an overwrite question
|
|
862
|
+
const q = page(RENAME_ROWS, {})
|
|
863
|
+
clickLabel(q.tree, '修改')
|
|
864
|
+
q.tree = q.renderAgain()
|
|
865
|
+
typeWithin(q.tree, 'dbh-editrow', 1, 'mysql://127.0.0.1:3400/app')
|
|
866
|
+
q.tree = q.renderAgain()
|
|
867
|
+
clickWithin(q.tree, 'dbh-editrow', '保存')
|
|
868
|
+
q.tree = q.renderAgain()
|
|
869
|
+
check('editing the same environment is not questioned', count(q.tree, 'dbh-warncard') === 0)
|
|
870
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
871
|
+
check('and goes straight to the pre-test', q.opCalls.some((o) => o.op === 'test' && o.env === 'default'), JSON.stringify(q.opCalls))
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
console.log('\n[17] copying a connection label yields the exact string (no separator)')
|
|
875
|
+
{
|
|
876
|
+
// The reported flaw: the label renders as two flex items, and the browser's
|
|
877
|
+
// own copy serialization inserts a space between block-level boxes, so pasting
|
|
878
|
+
// "…3306/app" into the DSN field produced "…3306/ app".
|
|
879
|
+
const p = page(TILED, {})
|
|
880
|
+
const label = classed(p.tree, 'dbh-conn')[0]
|
|
881
|
+
check('the label owns its copy behaviour', !!label && typeof label.props.onCopy === 'function')
|
|
882
|
+
let written = null
|
|
883
|
+
let prevented = false
|
|
884
|
+
const event = {
|
|
885
|
+
clipboardData: { setData: (type, value) => { written = { type, value } } },
|
|
886
|
+
preventDefault: () => { prevented = true },
|
|
887
|
+
}
|
|
888
|
+
if (label) label.props.onCopy(event)
|
|
889
|
+
check('copy is taken over', prevented === true)
|
|
890
|
+
check('clipboard carries the exact metadata label', !!written && written.value === 'mysql://127.0.0.1:3306/app', JSON.stringify(written))
|
|
891
|
+
check('no separator sneaks in', !!written && !/\s/.test(written.value), JSON.stringify(written))
|
|
892
|
+
// grouped rows use the same label component (a fresh path: a lone workspace
|
|
893
|
+
// group opens by itself, so no expansion state from earlier mounts leaks in)
|
|
894
|
+
const grouped = page([
|
|
895
|
+
row('cp-app', 'D:/work/cp', 'default', 'mysql://127.0.0.1:3306/app'),
|
|
896
|
+
row('cp-app', 'D:/work/cp', 'prod', 'mysql://127.0.0.1:3307/app'),
|
|
897
|
+
], {})
|
|
898
|
+
const gLabel = classed(grouped.tree, 'dbh-conn')[0]
|
|
899
|
+
check('grouped rows are covered too', !!gLabel && typeof gLabel.props.onCopy === 'function')
|
|
900
|
+
let gWritten = null
|
|
901
|
+
if (gLabel) gLabel.props.onCopy({ clipboardData: { setData: (t, v) => { gWritten = v } }, preventDefault: () => {} })
|
|
902
|
+
check('grouped copy is clean as well', gWritten === 'mysql://127.0.0.1:3306/app', JSON.stringify(gWritten))
|
|
903
|
+
// a copy that cannot be claimed must not throw
|
|
904
|
+
let threw = null
|
|
905
|
+
try {
|
|
906
|
+
if (label) label.props.onCopy({})
|
|
907
|
+
} catch (e) { threw = e }
|
|
908
|
+
check('a copy event without clipboardData is tolerated', threw === null, threw && threw.message)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
console.log('\n[18] HTTP bridge transport (the dsh 0.2.x path)')
|
|
912
|
+
{
|
|
913
|
+
// No settingsScope on this line: the face must fall back to the plugin's own
|
|
914
|
+
// authenticated /api/dsh-dbhub-live bridge and render the same page.
|
|
915
|
+
const p = page(TILED, { transport: 'http', props: { useWorkspaces: wsSnap('D:/work/app') } })
|
|
916
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
917
|
+
p.tree = p.renderAgain()
|
|
918
|
+
const html = toHtml(p.tree)
|
|
919
|
+
check('the state route was read over the bridge', p.getCalls.some((u) => u.includes('dsh-dbhub-live/state')), JSON.stringify(p.getCalls))
|
|
920
|
+
check('rows arrive over fetch', count(p.tree, 'dbh-tile') === 3, `got ${count(p.tree, 'dbh-tile')}`)
|
|
921
|
+
check('connection metadata is rendered', html.includes('mysql://127.0.0.1:3306/') || count(p.tree, 'dbh-conn') === 3)
|
|
922
|
+
check('the empty pre-fetch paint does not survive', html.includes('docs') || count(p.tree, 'dbh-envname') === 3)
|
|
923
|
+
await p.props.setEnabled(false)
|
|
924
|
+
check('the toggle POSTs to the bridge', p.setCalls.some(([k, v]) => k === 'enabled' && v === false), JSON.stringify(p.setCalls))
|
|
925
|
+
await p.props.saveConfig({ updateIntervalDays: 3 })
|
|
926
|
+
check('option writes POST to the bridge', p.setCalls.some(([k, v]) => k === 'options' && v.updateIntervalDays === 3), JSON.stringify(p.setCalls))
|
|
927
|
+
await p.props.configOp({ op: 'remove', workspace: 'D:/work/app', env: 'default' })
|
|
928
|
+
check('connection commands POST to the bridge', p.opCalls.some((o) => o.op === 'remove'), JSON.stringify(p.opCalls))
|
|
929
|
+
// A second mount must be able to take over: the poll interval belongs to the
|
|
930
|
+
// subscription and goes away with it (a leak would hang this gate).
|
|
931
|
+
p.dispose()
|
|
932
|
+
|
|
933
|
+
// With the settings client present, the same page writes its options through
|
|
934
|
+
// the plugin's own settings form (`ctx.configForms.get(entryId).set(...)`) —
|
|
935
|
+
// the sanctioned dsh 0.1.7+/0.2.x path — and keeps the bridge route unused.
|
|
936
|
+
const q = page(TILED, {
|
|
937
|
+
transport: 'http',
|
|
938
|
+
configForms: true,
|
|
939
|
+
snapshot: { entryId: 'dbhub-live' },
|
|
940
|
+
props: { useWorkspaces: wsSnap('D:/work/app') },
|
|
941
|
+
})
|
|
942
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
943
|
+
q.tree = q.renderAgain()
|
|
944
|
+
check('the Host view carries the settings entry id', q.getCalls.length > 0)
|
|
945
|
+
await q.props.saveConfig({ updateIntervalDays: 4 })
|
|
946
|
+
check('option writes go through the settings form',
|
|
947
|
+
q.formSets.some(([id, field, value]) => id === 'dbhub-live' && field === 'updateIntervalDays' && value === 4),
|
|
948
|
+
JSON.stringify(q.formSets))
|
|
949
|
+
check('the bridge route stays unused for that write', !q.setCalls.some(([k]) => k === 'options'), JSON.stringify(q.setCalls))
|
|
950
|
+
// An unresolved entry id must not lose the write: it falls back to the bridge.
|
|
951
|
+
const r = page(TILED, { transport: 'http', configForms: true, props: { useWorkspaces: wsSnap('D:/work/app') } })
|
|
952
|
+
await new Promise((res) => setTimeout(res, 0))
|
|
953
|
+
r.tree = r.renderAgain()
|
|
954
|
+
await r.props.saveConfig({ updateIntervalDays: 6 })
|
|
955
|
+
check('an unresolved entry id falls back to the bridge',
|
|
956
|
+
r.setCalls.some(([k, v]) => k === 'options' && v.updateIntervalDays === 6) && r.formSets.length === 0,
|
|
957
|
+
JSON.stringify({ sets: r.setCalls, forms: r.formSets }))
|
|
958
|
+
q.dispose()
|
|
959
|
+
r.dispose()
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
console.log('\n[19] disabling the plugin hides the sidebar entry (the settings page stays)')
|
|
963
|
+
{
|
|
964
|
+
// F0: entry visible ⇔ plugin enabled. The settings section and the Plugins row
|
|
965
|
+
// are NOT part of that equation — otherwise disabling would lock the user out
|
|
966
|
+
// of the very switch that re-enables it.
|
|
967
|
+
const p = page(TILED, { transport: 'http', props: { useWorkspaces: wsSnap('D:/work/app') } })
|
|
968
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
969
|
+
p.tree = p.renderAgain()
|
|
970
|
+
check('sidebar entry registered while enabled', !!p.registrations['sidebar.panellist'])
|
|
971
|
+
check('settings section registered while enabled', !!p.registrations['settings.section'])
|
|
972
|
+
await p.props.setEnabled(false)
|
|
973
|
+
p.tree = p.renderAgain()
|
|
974
|
+
check('disabling disposes the sidebar entry', !p.registrations['sidebar.panellist'])
|
|
975
|
+
check('the settings page survives the disable', !!p.registrations['settings.section'])
|
|
976
|
+
check('the Plugins row survives the disable', !!p.registrations['plugins.row.config'])
|
|
977
|
+
check('the page still offers the enable button', toHtml(p.tree).includes('启用'))
|
|
978
|
+
await p.props.setEnabled(true)
|
|
979
|
+
p.tree = p.renderAgain()
|
|
980
|
+
check('re-enabling brings the entry back', !!p.registrations['sidebar.panellist'])
|
|
981
|
+
// The disabled hint is what tells the user where the entry went. The strip's
|
|
982
|
+
// open state is page-session state shared with earlier groups, so only open it
|
|
983
|
+
// when it is actually closed.
|
|
984
|
+
await p.props.setEnabled(false)
|
|
985
|
+
p.tree = p.renderAgain()
|
|
986
|
+
if (count(p.tree, 'dbh-strip') === 0) {
|
|
987
|
+
click(p.tree, '设置')
|
|
988
|
+
p.tree = p.renderAgain()
|
|
989
|
+
}
|
|
990
|
+
check('the settings strip explains the hidden entry', toHtml(p.tree).includes('侧边栏入口已隐藏'), toHtml(p.tree).slice(0, 400))
|
|
991
|
+
p.dispose()
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
console.log('\n[20] read-only switch → op payload → inline row marker')
|
|
995
|
+
{
|
|
996
|
+
const rows = [
|
|
997
|
+
row('ro-app', 'D:/work/ro', 'default', 'mysql://127.0.0.1:3306/app'),
|
|
998
|
+
row('ro-app', 'D:/work/ro', 'prod', 'mysql://127.0.0.1:3307/app', { ro: true }),
|
|
999
|
+
]
|
|
1000
|
+
const p = page(rows, {})
|
|
1001
|
+
check('the lone workspace is open', count(p.tree, 'dbh-envrow') === 2)
|
|
1002
|
+
check('only the read-only row carries the marker', count(p.tree, 'dbh-ro') === 1, `got ${count(p.tree, 'dbh-ro')}`)
|
|
1003
|
+
const chips = classed(p.tree, 'dbh-envname')
|
|
1004
|
+
check('the marker sits inside the environment chip', toHtml(chips[1]).includes('只读'))
|
|
1005
|
+
check('the chip tooltip explains read-only', String(chips[1].props.title || '').includes('只读模式'))
|
|
1006
|
+
check('the writable row has no marker', !toHtml(chips[0]).includes('只读'))
|
|
1007
|
+
// the add form carries the switch, and it rides the pre-test payload
|
|
1008
|
+
click(p.tree, '+ 添加连接')
|
|
1009
|
+
p.tree = p.renderAgain()
|
|
1010
|
+
check('add panel offers the read-only switch', toHtml(p.tree).includes('只读模式'))
|
|
1011
|
+
check('the add panel still has exactly 3 fields (no new .dbh-field)', classed(p.tree, 'dbh-field').length === 3)
|
|
1012
|
+
const addInputs = classed(classed(p.tree, 'dbh-addpanel')[0], 'dbh-input')
|
|
1013
|
+
check('the read-only box is not a .dbh-input', addInputs.length === 3, `got ${addInputs.length}`)
|
|
1014
|
+
checkBoxWithin(p.tree, 'dbh-addpanel', 0, true)
|
|
1015
|
+
p.tree = p.renderAgain()
|
|
1016
|
+
typeWithin(p.tree, 'dbh-addpanel', 1, 'staging')
|
|
1017
|
+
p.tree = p.renderAgain()
|
|
1018
|
+
typeWithin(p.tree, 'dbh-addpanel', 2, 'mysql://127.0.0.1:3401/app')
|
|
1019
|
+
p.tree = p.renderAgain()
|
|
1020
|
+
clickWithin(p.tree, 'dbh-addpanel', '添加连接')
|
|
1021
|
+
p.tree = p.renderAgain()
|
|
1022
|
+
check('the pre-test payload carries readOnly: true',
|
|
1023
|
+
p.opCalls.some((o) => o.op === 'test' && o.readOnly === true && o.env === 'staging'),
|
|
1024
|
+
JSON.stringify(p.opCalls))
|
|
1025
|
+
// Turning it back OFF on the read-only row goes through the options op (no DSN
|
|
1026
|
+
// needed). The prod row is the second one in the group.
|
|
1027
|
+
const q = page(rows, {})
|
|
1028
|
+
clickLabelAt(q.tree, '修改', 1)
|
|
1029
|
+
q.tree = q.renderAgain()
|
|
1030
|
+
check('the editor pre-loads the row read-only state',
|
|
1031
|
+
elements(q.tree, (el) => el.type === 'input' && el.props.checked === true && String(el.props.className || '').includes('dbh-checkbox')).length >= 1)
|
|
1032
|
+
checkBoxWithin(q.tree, 'dbh-editrow', 0, false)
|
|
1033
|
+
q.tree = q.renderAgain()
|
|
1034
|
+
clickWithin(q.tree, 'dbh-editrow', '保存')
|
|
1035
|
+
q.tree = q.renderAgain()
|
|
1036
|
+
check('flipping read-only off sends an options op (no DSN)',
|
|
1037
|
+
q.opCalls.some((o) => o.op === 'options' && o.env === 'prod' && o.readOnly === false && o.newEnv === undefined),
|
|
1038
|
+
JSON.stringify(q.opCalls))
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
console.log('\n[21] advanced SSH tunnel: expand → fill → payload, secrets never rendered')
|
|
1042
|
+
{
|
|
1043
|
+
const p = page(TILED, { props: { useWorkspaces: wsSnap('D:/work/app') } })
|
|
1044
|
+
click(p.tree, '+ 添加连接')
|
|
1045
|
+
p.tree = p.renderAgain()
|
|
1046
|
+
check('advanced section is folded', count(p.tree, 'dbh-advbody') === 0)
|
|
1047
|
+
clickWithin(p.tree, 'dbh-addpanel', '高级:SSH 隧道')
|
|
1048
|
+
p.tree = p.renderAgain()
|
|
1049
|
+
check('advanced section expands', count(p.tree, 'dbh-advbody') === 1)
|
|
1050
|
+
check('fields stay hidden until the tunnel is enabled', classed(p.tree, 'dbh-advrow').length === 0,
|
|
1051
|
+
`got ${classed(p.tree, 'dbh-advrow').length}`)
|
|
1052
|
+
// checkbox 0 = read-only, checkbox 1 = "connect through a tunnel"
|
|
1053
|
+
checkBoxWithin(p.tree, 'dbh-addpanel', 1, true)
|
|
1054
|
+
p.tree = p.renderAgain()
|
|
1055
|
+
const rows = classed(p.tree, 'dbh-advrow')
|
|
1056
|
+
check('host/port/user/auth/key/passphrase/proxy fields appear', rows.length >= 7, `got ${rows.length}`)
|
|
1057
|
+
check('the host hint warns about aliases and the slow handshake',
|
|
1058
|
+
toHtml(classed(p.tree, 'dbh-advbody')[0]).includes('域名或 IP'))
|
|
1059
|
+
typeWithin(p.tree, 'dbh-advbody', 0, 'bastion.example.com')
|
|
1060
|
+
p.tree = p.renderAgain()
|
|
1061
|
+
typeWithin(p.tree, 'dbh-advbody', 1, '2222')
|
|
1062
|
+
p.tree = p.renderAgain()
|
|
1063
|
+
typeWithin(p.tree, 'dbh-advbody', 2, 'ops')
|
|
1064
|
+
p.tree = p.renderAgain()
|
|
1065
|
+
typeWithin(p.tree, 'dbh-advbody', 3, '~/.ssh/id_ed25519')
|
|
1066
|
+
p.tree = p.renderAgain()
|
|
1067
|
+
// A free environment name: writing over an existing one would raise the
|
|
1068
|
+
// overwrite gate before the pre-test runs.
|
|
1069
|
+
typeWithin(p.tree, 'dbh-addpanel', 1, 'sshbox')
|
|
1070
|
+
p.tree = p.renderAgain()
|
|
1071
|
+
typeWithin(p.tree, 'dbh-addpanel', 2, 'mysql://127.0.0.1:3402/app')
|
|
1072
|
+
p.tree = p.renderAgain()
|
|
1073
|
+
clickWithin(p.tree, 'dbh-addpanel', '添加连接')
|
|
1074
|
+
p.tree = p.renderAgain()
|
|
1075
|
+
const sent = p.opCalls.find((o) => o.op === 'test' && o.ssh)
|
|
1076
|
+
check('the pre-test payload carries the ssh block', !!sent, JSON.stringify(p.opCalls))
|
|
1077
|
+
check('ssh host/port/user/auth travel as non-secret fields',
|
|
1078
|
+
!!sent && sent.ssh.host === 'bastion.example.com' && sent.ssh.port === '2222' && sent.ssh.user === 'ops' && sent.ssh.auth === 'key',
|
|
1079
|
+
JSON.stringify(sent && sent.ssh))
|
|
1080
|
+
check('the key path is sent, the blank passphrase is NOT (blank = keep)',
|
|
1081
|
+
!!sent && sent.ssh.keyPath === '~/.ssh/id_ed25519' && sent.ssh.passphrase === undefined,
|
|
1082
|
+
JSON.stringify(sent && sent.ssh))
|
|
1083
|
+
check('no secret reaches the rendered page', !toHtml(p.tree).includes('CHANGE_ME'))
|
|
1084
|
+
// The tunnel state is metadata on the row: it rides the chip tooltip.
|
|
1085
|
+
const rowsWithSsh = [
|
|
1086
|
+
row('ssh-app', 'D:/work/ssh', 'default', 'mysql://127.0.0.1:3306/app', {
|
|
1087
|
+
ssh: { host: 'bastion.example.com', port: 22, user: 'ops', authKind: 'key', hasPassword: false, hasPassphrase: false, keyReady: true, proxyJump: '' },
|
|
1088
|
+
}),
|
|
1089
|
+
]
|
|
1090
|
+
const q = page(rowsWithSsh, {})
|
|
1091
|
+
const chip = classed(q.tree, 'dbh-envname')[0]
|
|
1092
|
+
check('a tunnelled row says so in its chip tooltip', String(chip.props.title || '').includes('SSH 隧道 bastion.example.com:22'))
|
|
1093
|
+
check('no secret is rendered for that row', !toHtml(q.tree).includes('id_ed25519'))
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
console.log('\n[23] the SSH-layer-only test is a separate button with its own payload')
|
|
1097
|
+
{
|
|
1098
|
+
const p = page(TILED, { props: { useWorkspaces: wsSnap('D:/work/app') } })
|
|
1099
|
+
click(p.tree, '+ 添加连接')
|
|
1100
|
+
p.tree = p.renderAgain()
|
|
1101
|
+
// `uiMemory.advOpen` is module-level (it survives remounts on purpose), so an
|
|
1102
|
+
// earlier group may already have left the section open — never blind-toggle.
|
|
1103
|
+
if (count(p.tree, 'dbh-advbody') === 0) {
|
|
1104
|
+
clickWithin(p.tree, 'dbh-addpanel', '高级:SSH 隧道')
|
|
1105
|
+
p.tree = p.renderAgain()
|
|
1106
|
+
}
|
|
1107
|
+
check('the advanced section is open for the SSH test', count(p.tree, 'dbh-advbody') === 1)
|
|
1108
|
+
checkBoxWithin(p.tree, 'dbh-addpanel', 1, true)
|
|
1109
|
+
p.tree = p.renderAgain()
|
|
1110
|
+
check('the dedicated SSH test button is offered next to the connection test',
|
|
1111
|
+
toHtml(classed(p.tree, 'dbh-advbody')[0]).includes('测试 SSH 隧道'))
|
|
1112
|
+
// An empty block is still dispatched: the HOST answers with the named missing
|
|
1113
|
+
// fields (one source of truth for that message), and it never spawns dbhub.
|
|
1114
|
+
const before = p.opCalls.length
|
|
1115
|
+
clickWithin(p.tree, 'dbh-advbody', '测试 SSH 隧道')
|
|
1116
|
+
p.tree = p.renderAgain()
|
|
1117
|
+
const sent = p.opCalls.slice(before).filter((o) => o.op === 'test' && o.kind === 'ssh').pop()
|
|
1118
|
+
check('the SSH test dispatches {op:test, kind:ssh} with the tunnel block',
|
|
1119
|
+
!!sent && sent.ssh && sent.ssh.auth === 'key', JSON.stringify(sent))
|
|
1120
|
+
check('the SSH test carries its own nonce', !!sent && typeof sent.nonce === 'string' && sent.nonce.includes('sshtest'))
|
|
1121
|
+
check('the SSH test is NOT the connection test (no dsn in its payload)',
|
|
1122
|
+
!!sent && sent.dsn === undefined, JSON.stringify(sent))
|
|
1123
|
+
check('the pending SSH test shows its elapsed time', toHtml(p.tree).includes('测试中…'), toHtml(p.tree).slice(0, 300))
|
|
1124
|
+
// A second click while the probe is in flight must NOT dispatch again (the
|
|
1125
|
+
// button is disabled and the guard in startSshTest is the real backstop).
|
|
1126
|
+
const inFlight = p.opCalls.length
|
|
1127
|
+
clickWithin(p.tree, 'dbh-advbody', '测试 SSH 隧道')
|
|
1128
|
+
check('a second click during the probe is ignored', p.opCalls.length === inFlight, JSON.stringify(p.opCalls.slice(inFlight)))
|
|
1129
|
+
// Disposal clears the pending watchdog timer (a surviving 60s timeout would
|
|
1130
|
+
// keep this gate alive after the last check).
|
|
1131
|
+
p.dispose()
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
console.log('\n[22] a running probe shows its elapsed time and cleans the ticker up')
|
|
1135
|
+
{
|
|
1136
|
+
const p = page(TILED, { props: { useWorkspaces: wsSnap('D:/work/app') } })
|
|
1137
|
+
clickLabel(p.tree, '测试')
|
|
1138
|
+
p.tree = p.renderAgain()
|
|
1139
|
+
check('the testing state is shown', toHtml(p.tree).includes('测试中… 0s'), toHtml(p.tree).slice(0, 200))
|
|
1140
|
+
const sent = p.opCalls.filter((o) => o.op === 'test' && o.nonce)
|
|
1141
|
+
check('exactly one probe was dispatched', sent.length === 1, JSON.stringify(p.opCalls))
|
|
1142
|
+
await new Promise((r) => setTimeout(r, 1100))
|
|
1143
|
+
p.tree = p.renderAgain()
|
|
1144
|
+
check('the elapsed seconds advance', /测试中… [1-9]\ds?/.test(toHtml(p.tree)) || toHtml(p.tree).includes('测试中… 1s'), toHtml(p.tree).slice(0, 300))
|
|
1145
|
+
// Disposal must clear the 1s ticker: this gate hangs (and CI times out) if a
|
|
1146
|
+
// per-row interval survives the unmount.
|
|
1147
|
+
p.dispose()
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
console.log('\n[24] modern wide page: quiet header, DSH-style settings rows, inline read-only chip')
|
|
1151
|
+
{
|
|
1152
|
+
viewport(1440)
|
|
1153
|
+
const p = page(TILED, {
|
|
1154
|
+
slot: 'main',
|
|
1155
|
+
snapshot: Object.assign({}, MODERN_DEFAULTS),
|
|
1156
|
+
props: { useWorkspaces: wsSnap('D:/work/app') },
|
|
1157
|
+
})
|
|
1158
|
+
let html = toHtml(p.tree)
|
|
1159
|
+
check('wide surface class applied', classed(p.tree, 'dbh-wide').length === 1, html.slice(0, 200))
|
|
1160
|
+
check('modern class applied', classed(p.tree, 'dbh-modern').length === 1)
|
|
1161
|
+
check('title bar rendered', count(p.tree, 'dbh-titlebar') === 1)
|
|
1162
|
+
check('title is the plugin name', classed(p.tree, 'dbh-title').map((el) => toHtml(el)).some((t) => t.includes('DBHub 数据库工具')), toHtml(classed(p.tree, 'dbh-title')[0]))
|
|
1163
|
+
check('current version shown next to the title', html.includes('v5.2.0-dev.1'))
|
|
1164
|
+
check('no update UI when already current', classed(p.tree, 'dbh-updatelink').length === 0 && count(p.tree, 'dbh-updatepanel') === 0)
|
|
1165
|
+
check('update check button offered (icon only)', classed(p.tree, 'dbh-vercheck').length === 1)
|
|
1166
|
+
check('status line is state + connection count', count(p.tree, 'dbh-statusline') === 1 && html.includes('运行中') && html.includes('个连接'))
|
|
1167
|
+
check('tool count / one-shot mode no longer in the header', !html.includes('4 个工具') && !html.includes('固定'), html.slice(0, 300))
|
|
1168
|
+
check('no runtime badge in the header', !html.includes('Web 版') && !html.includes('桌面版'))
|
|
1169
|
+
check('settings bar visible (wide page owns it)', count(p.tree, 'dbh-settings') === 1 && count(p.tree, 'dbh-settingsbar') === 1)
|
|
1170
|
+
check('four settings groups on one compact line', count(p.tree, 'dbh-bargroup') === 4, `got ${count(p.tree, 'dbh-bargroup')}`)
|
|
1171
|
+
check('three harness Switch controls (enable / sidebar / policy)', switches(p.tree).length === 3, `got ${switches(p.tree).length}`)
|
|
1172
|
+
check('dbhub update interval stays on the same line', classed(p.tree, 'dbh-srow-input').length === 1)
|
|
1173
|
+
check('no stretched settings rows in wide mode', count(p.tree, 'dbh-srow') === 0)
|
|
1174
|
+
check('no old four-cell grid / gear strip', count(p.tree, 'dbh-config') === 0 && count(p.tree, 'dbh-strip') === 0)
|
|
1175
|
+
check('a read-only chip per row (outside the edit form)', classed(p.tree, 'dbh-rochip').length === 3, `got ${classed(p.tree, 'dbh-rochip').length}`)
|
|
1176
|
+
check('row actions stay icon-only (source + test + edit + delete)', classed(p.tree, 'dbh-iconbtn').length === 8, `got ${classed(p.tree, 'dbh-iconbtn').length}`)
|
|
1177
|
+
// Notes are collapsed: the page acts first; the "why" is one click away.
|
|
1178
|
+
check('technical details start collapsed', count(p.tree, 'dbh-notes') === 0)
|
|
1179
|
+
clickClass(p.tree, 'dbh-linkbtn') // ⓘ 说明与技术详情
|
|
1180
|
+
p.tree = p.renderAgain()
|
|
1181
|
+
html = toHtml(p.tree)
|
|
1182
|
+
check('notes expand with tool count / policy / versions', count(p.tree, 'dbh-notes') === 1 && html.includes('4 个工具') && html.includes('v5.2.0-dev.1'))
|
|
1183
|
+
// Opening the page asks the Host for a THROTTLED update check (op only; the
|
|
1184
|
+
// Host owns the network call and the cache).
|
|
1185
|
+
const checks = p.opCalls.filter((o) => o.op === 'updateCheck')
|
|
1186
|
+
check('page open dispatches a throttled update check', checks.length === 1 && checks[0].force === false, JSON.stringify(p.opCalls))
|
|
1187
|
+
const before = p.setCalls.length
|
|
1188
|
+
clickSwitchWithin(p.tree, 'dbh-settingsbar', 0)
|
|
1189
|
+
check('enable switch disables the plugin', p.setCalls.slice(before).some(([k, v]) => k === 'enabled' && v === false), JSON.stringify(p.setCalls.slice(before)))
|
|
1190
|
+
p.tree = p.renderAgain()
|
|
1191
|
+
const beforeSidebar = p.setCalls.length
|
|
1192
|
+
clickSwitchWithin(p.tree, 'dbh-settingsbar', 1)
|
|
1193
|
+
// saveConfig chains one `host.set` per field through a promise, so the write
|
|
1194
|
+
// lands on the next microtask (the immediate-apply contract, not a sync call).
|
|
1195
|
+
await Promise.resolve()
|
|
1196
|
+
await Promise.resolve()
|
|
1197
|
+
check('sidebar switch saves 显示/隐藏 preference', p.setCalls.slice(beforeSidebar).some(([k, v]) => k === 'showSidebarEntry' && v === false), JSON.stringify(p.setCalls.slice(beforeSidebar)))
|
|
1198
|
+
// The row chip is an options-only op: never a DSN.
|
|
1199
|
+
const beforeOps = p.opCalls.length
|
|
1200
|
+
clickClass(p.tree, 'dbh-rochip')
|
|
1201
|
+
const roOp = p.opCalls.slice(beforeOps).find((o) => o.op === 'options')
|
|
1202
|
+
check('row read-only chip writes {op:options, readOnly:true}', !!roOp && roOp.readOnly === true, JSON.stringify(roOp))
|
|
1203
|
+
check('row read-only chip carries no dsn', !!roOp && roOp.dsn === undefined, JSON.stringify(roOp))
|
|
1204
|
+
p.dispose()
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
console.log('\n[25] modern update link: quiet by default, neutral detail panel on click')
|
|
1208
|
+
{
|
|
1209
|
+
const update = JSON.stringify({
|
|
1210
|
+
latest: '5.2.0', latestSpec: 'dsh-dbhub-live@5.2.0', hasUpdate: true,
|
|
1211
|
+
checkedAt: Date.now(), source: 'npm', error: '',
|
|
1212
|
+
})
|
|
1213
|
+
viewport(1440)
|
|
1214
|
+
const p = page(TILED, {
|
|
1215
|
+
slot: 'main',
|
|
1216
|
+
snapshot: Object.assign({}, MODERN_DEFAULTS, { update }),
|
|
1217
|
+
props: { useWorkspaces: wsSnap('D:/work/app') },
|
|
1218
|
+
})
|
|
1219
|
+
let html = toHtml(p.tree)
|
|
1220
|
+
check('update surfaces as a quiet text link', classed(p.tree, 'dbh-updatelink').length === 1 && html.includes('可升级到 5.2.0'), html.slice(0, 300))
|
|
1221
|
+
check('no red/error-styled update card exists', classed(p.tree, 'dbh-latest').length === 0 && count(p.tree, 'dbh-updatecard') === 0)
|
|
1222
|
+
check('details stay collapsed until asked for', count(p.tree, 'dbh-updatepanel') === 0)
|
|
1223
|
+
clickClass(p.tree, 'dbh-updatelink')
|
|
1224
|
+
p.tree = p.renderAgain()
|
|
1225
|
+
html = toHtml(p.tree)
|
|
1226
|
+
check('clicking the link opens the neutral detail panel', count(p.tree, 'dbh-updatepanel') === 1)
|
|
1227
|
+
check('panel carries the install spec', html.includes('dsh-dbhub-live@5.2.0'))
|
|
1228
|
+
check('web runtime gets the web update command', html.includes('dsh plugin --profile web up dsh-dbhub-live@5.2.0'))
|
|
1229
|
+
const releaseHref = elements(p.tree, (el) => typeof el.props.href === 'string').map((el) => el.props.href)
|
|
1230
|
+
check('release notes link points at the GitHub release', releaseHref.some((h) => h.includes('https://github.com/mr-mihu/dsh-dbhub-live/releases/tag/v5.2.0')), JSON.stringify(releaseHref))
|
|
1231
|
+
// The copy control copies the exact install spec (the thing a person pastes).
|
|
1232
|
+
let copied = ''
|
|
1233
|
+
const previousClipboard = primitivesStub.writeClipboard
|
|
1234
|
+
primitivesStub.writeClipboard = (text) => { copied = String(text); return Promise.resolve(true) }
|
|
1235
|
+
clickClass(p.tree, 'dbh-linkbtn')
|
|
1236
|
+
primitivesStub.writeClipboard = previousClipboard
|
|
1237
|
+
check('copy control copies dsh-dbhub-live@<latest>', copied === 'dsh-dbhub-live@5.2.0', JSON.stringify(copied))
|
|
1238
|
+
// The explicit check button forces a refresh (bypassing the Host throttle).
|
|
1239
|
+
const before = p.opCalls.length
|
|
1240
|
+
clickClass(p.tree, 'dbh-vercheck')
|
|
1241
|
+
check('check button forces a fresh check', p.opCalls.slice(before).some((o) => o.op === 'updateCheck' && o.force === true), JSON.stringify(p.opCalls.slice(before)))
|
|
1242
|
+
p.dispose()
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
console.log('\n[26] modern update panel on desktop: uninstall/reinstall guidance, no web command')
|
|
1246
|
+
{
|
|
1247
|
+
viewport(1440)
|
|
1248
|
+
const update = JSON.stringify({
|
|
1249
|
+
latest: '5.2.0', latestSpec: 'dsh-dbhub-live@5.2.0', hasUpdate: true,
|
|
1250
|
+
checkedAt: Date.now(), source: 'npm', error: '',
|
|
1251
|
+
})
|
|
1252
|
+
const p = page(TILED, {
|
|
1253
|
+
slot: 'main',
|
|
1254
|
+
snapshot: Object.assign({}, MODERN_DEFAULTS, { runtime: 'desktop', update }),
|
|
1255
|
+
props: { useWorkspaces: wsSnap('D:/work/app') },
|
|
1256
|
+
})
|
|
1257
|
+
let html = toHtml(p.tree)
|
|
1258
|
+
check('no desktop runtime badge in the header', !html.includes('桌面版'))
|
|
1259
|
+
clickClass(p.tree, 'dbh-updatelink')
|
|
1260
|
+
p.tree = p.renderAgain()
|
|
1261
|
+
html = toHtml(p.tree)
|
|
1262
|
+
check('desktop guidance says uninstall + reinstall', html.includes('卸载') && html.includes('重新安装'))
|
|
1263
|
+
check('desktop guidance keeps the data location', html.includes('~/.dsh/storages/dsh-dbhub-live'))
|
|
1264
|
+
check('desktop panel offers no web install command', !html.includes('dsh plugin --profile web up'), html.slice(0, 400))
|
|
1265
|
+
p.dispose()
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
console.log('\n[27] modern settings dialog: compact header, 设置 disclosure, read-only chips visible')
|
|
1269
|
+
{
|
|
1270
|
+
viewport(1440)
|
|
1271
|
+
const p = page(TILED, {
|
|
1272
|
+
snapshot: Object.assign({}, MODERN_DEFAULTS),
|
|
1273
|
+
props: { useWorkspaces: wsSnap('D:/work/app') },
|
|
1274
|
+
})
|
|
1275
|
+
let html = toHtml(p.tree)
|
|
1276
|
+
check('modal surface class applied', classed(p.tree, 'dbh-modal').length === 1)
|
|
1277
|
+
check('modal is not the wide page', classed(p.tree, 'dbh-wide').length === 0)
|
|
1278
|
+
check('modal shows the title + version header', count(p.tree, 'dbh-titlebar') === 1 && html.includes('v5.2.0-dev.1'))
|
|
1279
|
+
check('no legacy header or gear strip in the modern dialog', count(p.tree, 'dbh-head') === 0 && count(p.tree, 'dbh-strip') === 0 && !html.includes('⚙'))
|
|
1280
|
+
check('settings sit behind the status-line toggle (not in the header)', html.includes('设置') && classed(p.tree, 'dbh-linkbtn').length >= 1)
|
|
1281
|
+
check('read-only chip is available in the dialog too', classed(p.tree, 'dbh-rochip').length === 3, `got ${classed(p.tree, 'dbh-rochip').length}`)
|
|
1282
|
+
// uiMemory survives mounts, so an earlier group may have left the disclosure
|
|
1283
|
+
// open; either way the toggle must own the rows and close them on the next click.
|
|
1284
|
+
if (count(p.tree, 'dbh-settings') === 0) {
|
|
1285
|
+
clickClass(p.tree, 'dbh-linkbtn')
|
|
1286
|
+
p.tree = p.renderAgain()
|
|
1287
|
+
}
|
|
1288
|
+
html = toHtml(p.tree)
|
|
1289
|
+
check('the dialog opens the same DSH-style rows', count(p.tree, 'dbh-settings') === 1 && count(p.tree, 'dbh-srow') === 4)
|
|
1290
|
+
check('sidebar/policy controls are harness switches', switches(p.tree).length === 3)
|
|
1291
|
+
const modernRows = classed(p.tree, 'dbh-settings')[0]
|
|
1292
|
+
check('no legacy tick-box left in the settings rows', elements(modernRows, (el) => el.type === 'input' && String(el.props.className || '').includes('dbh-checkbox')).length === 0)
|
|
1293
|
+
clickClass(p.tree, 'dbh-linkbtn')
|
|
1294
|
+
p.tree = p.renderAgain()
|
|
1295
|
+
check('the same toggle collapses the rows again', count(p.tree, 'dbh-settings') === 0)
|
|
1296
|
+
p.dispose()
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
console.log('\n[28] modern phone browser: stacked layout, chips and settings still reachable')
|
|
1300
|
+
{
|
|
1301
|
+
viewport(390)
|
|
1302
|
+
const p = page(TILED, {
|
|
1303
|
+
slot: 'main',
|
|
1304
|
+
snapshot: Object.assign({}, MODERN_DEFAULTS),
|
|
1305
|
+
props: { useWorkspaces: wsSnap('D:/work/app') },
|
|
1306
|
+
})
|
|
1307
|
+
const html = toHtml(p.tree)
|
|
1308
|
+
check('mobile surface class applied', classed(p.tree, 'dbh-mobile').length === 1, html.slice(0, 200))
|
|
1309
|
+
check('phone layout is not the wide page', classed(p.tree, 'dbh-wide').length === 0)
|
|
1310
|
+
check('phone shows the title + version', count(p.tree, 'dbh-titlebar') === 1 && html.includes('v5.2.0-dev.1'))
|
|
1311
|
+
check('phone keeps the icon-only row actions', classed(p.tree, 'dbh-iconbtn').length === 8)
|
|
1312
|
+
check('read-only chips remain available on the phone', classed(p.tree, 'dbh-rochip').length === 3)
|
|
1313
|
+
if (count(p.tree, 'dbh-settings') === 0) {
|
|
1314
|
+
clickClass(p.tree, 'dbh-linkbtn')
|
|
1315
|
+
p.tree = p.renderAgain()
|
|
1316
|
+
}
|
|
1317
|
+
check('phone can open the settings rows', count(p.tree, 'dbh-srow') === 4)
|
|
1318
|
+
viewport(1440)
|
|
1319
|
+
p.dispose()
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
console.log('\n[29] dsh version gate: modern host fields on a 0.1.x runtime render the OLD page')
|
|
1323
|
+
{
|
|
1324
|
+
viewport(1440)
|
|
1325
|
+
const p = page(TILED, {
|
|
1326
|
+
slot: 'main',
|
|
1327
|
+
snapshot: Object.assign({}, MODERN_DEFAULTS, { dshModern: false }),
|
|
1328
|
+
props: { useWorkspaces: wsSnap('D:/work/app') },
|
|
1329
|
+
})
|
|
1330
|
+
const html = toHtml(p.tree)
|
|
1331
|
+
check('no modern class on a legacy runtime', classed(p.tree, 'dbh-modern').length === 0)
|
|
1332
|
+
check('no title bar on a legacy runtime', count(p.tree, 'dbh-titlebar') === 0)
|
|
1333
|
+
check('no settings rows on a legacy runtime', count(p.tree, 'dbh-settings') === 0)
|
|
1334
|
+
check('no inline read-only chip on a legacy runtime', classed(p.tree, 'dbh-rochip').length === 0)
|
|
1335
|
+
check('no harness Switch on a legacy runtime', switches(p.tree).length === 0)
|
|
1336
|
+
check('no update check dispatched on a legacy runtime', p.opCalls.filter((o) => o.op === 'updateCheck').length === 0, JSON.stringify(p.opCalls))
|
|
1337
|
+
check('legacy header still carries the gear + enable controls', html.includes('⚙') && html.includes('禁用'))
|
|
1338
|
+
check('legacy status text is unchanged (tool count stays)', html.includes('4 个工具'))
|
|
1339
|
+
p.dispose()
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
console.log('\n[30] narrow desktop window: non-modal pages keep the horizontal settings bar')
|
|
1343
|
+
{
|
|
1344
|
+
// 710px viewport: not a phone (>= 700) and not "wide" (< 720), which used to
|
|
1345
|
+
// fall back to four stacked rows — the desktop complaint. The bar must stay
|
|
1346
|
+
// horizontal, with its compact variant applied.
|
|
1347
|
+
viewport(710)
|
|
1348
|
+
const p = page(TILED, {
|
|
1349
|
+
slot: 'main',
|
|
1350
|
+
snapshot: Object.assign({}, MODERN_DEFAULTS),
|
|
1351
|
+
props: { useWorkspaces: wsSnap('D:/work/app') },
|
|
1352
|
+
})
|
|
1353
|
+
const html = toHtml(p.tree)
|
|
1354
|
+
check('narrow desktop still renders the settings bar', count(p.tree, 'dbh-settingsbar') === 1, html.slice(0, 200))
|
|
1355
|
+
check('narrow bar uses the compact variant', classed(p.tree, 'dbh-barnarrow').length === 1)
|
|
1356
|
+
check('no vertical settings rows on a narrow desktop', count(p.tree, 'dbh-srow') === 0)
|
|
1357
|
+
check('all four controls stay reachable', count(p.tree, 'dbh-bargroup') === 4 && switches(p.tree).length === 3)
|
|
1358
|
+
viewport(1440)
|
|
1359
|
+
p.dispose()
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (liveMount) liveMount.dispose()
|
|
1363
|
+
|
|
1364
|
+
console.log(`\n${failures === 0 ? 'ALL CHECKS PASSED' : failures + ' CHECK(S) FAILED'}`)
|
|
1365
|
+
process.exitCode = failures === 0 ? 0 : 1
|
|
1366
|
+
|