dsh-dbhub-live 5.1.0 → 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.
@@ -148,6 +148,19 @@ const primitivesStub = (() => {
148
148
  IconCloseOutline16: icon('ico-close'),
149
149
  IconTrashOutline16: icon('ico-trash'),
150
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' })),
151
164
  writeClipboard: () => Promise.resolve(true),
152
165
  }
153
166
  })()
@@ -461,8 +474,9 @@ function page(rows, opts = {}) {
461
474
  const snapshot = snapshotOf(rows, opts.snapshot)
462
475
  const m = mount(snapshot, opts)
463
476
  liveMount = m
464
- const entry = m.registrations['settings.section']
465
- if (!entry) throw new Error('settings.section was not registered')
477
+ const slot = opts.slot || 'settings.section'
478
+ const entry = m.registrations[slot]
479
+ if (!entry) throw new Error(`${slot} was not registered`)
466
480
  const props = Object.assign({}, entry.def.inject(), opts.props, { view: 'page' })
467
481
  const comp = entry.comp
468
482
  const renderAgain = () => react.render(comp, props)
@@ -470,6 +484,50 @@ function page(rows, opts = {}) {
470
484
  return Object.assign(m, { props, comp, renderAgain, tree: react.render(comp, props) })
471
485
  }
472
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
+
473
531
  console.log('\n[1] tiled layout (every workspace has exactly one environment)')
474
532
  {
475
533
  const p = page(TILED, { props: { useWorkspaces: wsSnap('D:/work/app') } })
@@ -610,6 +668,19 @@ console.log('\n[8] interaction: add panel (global + per group), settings strip,
610
668
  check('sidebar switch writes showSidebarEntry immediately',
611
669
  writes.some(([k, v]) => k === 'showSidebarEntry' && v === false), JSON.stringify(writes))
612
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))
613
684
  }
614
685
 
615
686
  console.log('\n[9] summary view (Plugins page one-liner)')
@@ -1076,7 +1147,220 @@ console.log('\n[22] a running probe shows its elapsed time and cleans the ticker
1076
1147
  p.dispose()
1077
1148
  }
1078
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
+
1079
1362
  if (liveMount) liveMount.dispose()
1080
1363
 
1081
1364
  console.log(`\n${failures === 0 ? 'ALL CHECKS PASSED' : failures + ' CHECK(S) FAILED'}`)
1082
1365
  process.exitCode = failures === 0 ? 0 : 1
1366
+
@@ -265,14 +265,16 @@ test('client bundle keeps the source handle copyable instead of echoing it inlin
265
265
  assert.doesNotMatch(source, /w\.dsn/)
266
266
  })
267
267
 
268
- test('client bundle uses product icons for row actions instead of text labels', () => {
268
+ test('client bundle draws clear row-action icons instead of text labels', () => {
269
269
  assert.match(source, /function pickIcon\(name, glyph\)/)
270
- assert.match(source, /typeof Icon === "function" \? Icon : function \(\) \{ return h\("span"/)
270
+ // Test / edit / delete are explicit 16px SVG shapes (the bundled edit glyph is
271
+ // ambiguous at 1x); copy and close keep their product primitives.
272
+ assert.match(source, /var svgIcon = function \(children\)/)
273
+ assert.match(source, /var RunIcon = svgIcon\(/)
274
+ assert.match(source, /var EditIcon = svgIcon\(/)
275
+ assert.match(source, /var TrashIcon = svgIcon\(/)
271
276
  assert.match(source, /pickIcon\("IconCopyOutline16"/)
272
- assert.match(source, /pickIcon\("IconEditOutline16"/)
273
277
  assert.match(source, /pickIcon\("IconCloseOutline16"/)
274
- assert.match(source, /pickIcon\("IconTrashOutline16"/)
275
- assert.match(source, /pickIcon\("IconPlayOutline16"/)
276
278
  // icons carry their label in title/aria-label, never as row text
277
279
  assert.match(source, /"aria-label": t\("btn\.test"\)/)
278
280
  assert.match(source, /"aria-label": t\("btn\.delete"\)/)
@@ -429,3 +431,85 @@ test('client bundle disables what the installed dbhub cannot do', () => {
429
431
  assert.match(source, /disabled: !sshSupported,/)
430
432
  assert.match(source, /disabled: !roSupported,/)
431
433
  })
434
+
435
+ test('client bundle gates every modern surface on the Host dsh version', () => {
436
+ // 5.2 surfaces (title/version header, update prompt, wide settings block,
437
+ // inline read-only toggle, switch-style controls) exist only on dsh >= 0.2.1.
438
+ // On 0.1.x the bundle must render the OLD DOM — the offscreen gate's legacy
439
+ // groups pin that, so the flag is read, never inferred from a slot existing.
440
+ assert.match(source, /var modern = value\.dshModern === true;/)
441
+ assert.match(source, /var pluginVersion = typeof value\.pluginVersion === "string"/)
442
+ assert.match(source, /var dshVersion = typeof value\.dshVersion === "string"/)
443
+ assert.match(source, /var runtime = value\.runtime === "desktop"/)
444
+ // The three mounts are named, not guessed: settings dialog vs full pages.
445
+ assert.match(source, /function SettingsSectionPanel\(props\)/)
446
+ assert.match(source, /function MainPanel\(props\)/)
447
+ assert.match(source, /surface: "modal"/)
448
+ assert.match(source, /surface: "main"/)
449
+ // Layout follows the measured column, with the phone check on the VIEWPORT
450
+ // (a narrow settings dialog on a desktop is not a phone).
451
+ assert.match(source, /function useMeasuredWidth\(ref\)/)
452
+ assert.match(source, /var isMobile = modern && \(viewportWidth > 0/)
453
+ assert.match(source, /var isWide = modern && !isMobile && surface !== "modal"/)
454
+ })
455
+
456
+ test('client bundle shows the plugin version and the quiet update prompt', () => {
457
+ assert.match(source, /"ver\.link": "可升级到 \{v\}"/)
458
+ assert.match(source, /"ver\.link": "Upgrade to \{v\}"/)
459
+ assert.match(source, /function parseUpdate\(value\)/)
460
+ // A text link by default; the instructions live in a panel opened on demand.
461
+ assert.match(source, /className: "dbh-updatelink"/)
462
+ assert.match(source, /className: "dbh-updatepanel"/)
463
+ assert.match(source, /t\("ver\.copySpec", \{ v: update\.latest \}\)/)
464
+ // the copy control copies the exact install spec; the web command is copiable too
465
+ assert.match(source, /dsh-dbhub-live@" \+ update\.latest/)
466
+ assert.match(source, /dsh plugin --profile web up dsh-dbhub-live@/)
467
+ // opening the page asks the Host for a throttled check; the button forces one
468
+ assert.match(source, /op: "updateCheck", force: false/)
469
+ assert.match(source, /op: "updateCheck", force: force === true/)
470
+ // Updating is neutral news, not an error: no red card/pill classes remain.
471
+ assert.doesNotMatch(source, /dbh-latest/)
472
+ assert.doesNotMatch(source, /dbh-updatecard/)
473
+ // desktop and web update routes differ
474
+ assert.match(source, /t\("ver\.desktopHint"/)
475
+ assert.match(source, /t\("ver\.webHint"/)
476
+ assert.match(source, /releases\/tag\/v/)
477
+ })
478
+
479
+ test('client bundle renders DSH-style settings rows, the harness Switch and row read-only chips', () => {
480
+ assert.match(source, /function switchControl\(opts\)/)
481
+ // The real toggle comes from the primitives module; the fallback is local.
482
+ assert.match(source, /primitives\.Switch/)
483
+ assert.match(source, /className: "dbh-fbswitch"/)
484
+ assert.match(source, /className: "dbh-settings"/)
485
+ assert.match(source, /className: "dbh-srow"/)
486
+ // Non-modal pages always use the horizontal bar (a narrow desktop column gets
487
+ // the compact variant instead of four stacked rows).
488
+ assert.match(source, /var useSettingsBar = modern && !isMobile && surface !== "modal"/)
489
+ assert.match(source, /className: "dbh-settings dbh-settingsbar"/)
490
+ // State words are two-character, current-state labels — never "已开启".
491
+ assert.match(source, /"row\.enable": "启用插件"/)
492
+ assert.match(source, /"state\.show": "显示"/)
493
+ assert.match(source, /"state\.hide": "隐藏"/)
494
+ assert.match(source, /"state\.on": "开启"/)
495
+ assert.match(source, /"state\.off": "关闭"/)
496
+ // Wide page owns the settings: enable / sidebar / policy switches + interval.
497
+ assert.match(source, /props\.setEnabled\(next\)/)
498
+ assert.match(source, /onChange: toggleSidebar/)
499
+ assert.match(source, /onChange: togglePolicy/)
500
+ // Read-only is a row chip in every layout, written as an options-only op.
501
+ assert.match(source, /className: "dbh-rochip"/)
502
+ assert.match(source, /op: "options", workspace: w\.path, env: w\.env, readOnly: w\.ro !== true/)
503
+ // The chip shows an OPEN lock + 可写 when writable and a CLOSED lock + 只读
504
+ // when locked, so a first-time user cannot mistake two writable rows for RO.
505
+ assert.match(source, /"dbh-roemoji"/)
506
+ assert.match(source, /on \? "🔒" : "🔓"/)
507
+ assert.match(source, /t\(on \? "ro\.chip" : "ro\.writable"\)/)
508
+ assert.match(source, /"ro\.writable": "可写"/)
509
+ assert.match(source, /"ro\.writable": "Writable"/)
510
+ // Long explanations are collapsed into the notes block.
511
+ assert.match(source, /className: "dbh-notes"/)
512
+ assert.match(source, /"note\.tools":/)
513
+ assert.match(source, /className: "dbh-titlebar"/)
514
+ assert.match(source, /className: "dbh-title"/)
515
+ })