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.
Files changed (50) hide show
  1. package/AGENTS.md +95 -209
  2. package/README.en.md +251 -169
  3. package/README.md +251 -169
  4. package/doc/ARCHITECTURE.md +108 -0
  5. package/doc/CLIENT.md +124 -0
  6. package/doc/DESENSITIZE.md +56 -0
  7. package/doc/DEVELOPMENT.md +139 -0
  8. package/doc/IMPACT-MATRIX.md +36 -0
  9. package/doc/RELEASE.md +93 -0
  10. package/doc/REQUIREMENTS.md +177 -105
  11. package/doc/images/chat-query.png +0 -0
  12. package/doc/images/row-editor.png +0 -0
  13. package/doc/images/settings-page.png +0 -0
  14. package/lib/adhoc.mjs +412 -34
  15. package/lib/capability.mjs +157 -0
  16. package/lib/client.js +1243 -80
  17. package/lib/config.mjs +370 -21
  18. package/lib/i18n.mjs +119 -4
  19. package/lib/index.mjs +401 -43
  20. package/lib/mcp.mjs +496 -391
  21. package/lib/meta.mjs +267 -0
  22. package/lib/options.mjs +25 -5
  23. package/lib/policy.mjs +92 -0
  24. package/lib/toml.mjs +188 -0
  25. package/lib/tools.mjs +1093 -827
  26. package/lib/update.mjs +244 -0
  27. package/package.json +34 -10
  28. package/scripts/desensitize-check.mjs +10 -3
  29. package/scripts/desensitize.config.mjs +255 -237
  30. package/scripts/docs-check.mjs +214 -0
  31. package/scripts/release-preflight.mjs +180 -0
  32. package/scripts/render-check.mjs +1366 -843
  33. package/test/adhoc-toml.test.mjs +365 -0
  34. package/test/bridge.test.mjs +227 -185
  35. package/test/capability.test.mjs +231 -0
  36. package/test/client-format.test.mjs +515 -352
  37. package/test/configure-options.test.mjs +244 -0
  38. package/test/configure-ssh.test.mjs +232 -0
  39. package/test/copy-from.test.mjs +155 -0
  40. package/test/env-options.test.mjs +456 -0
  41. package/test/meta.test.mjs +234 -0
  42. package/test/options.test.mjs +160 -123
  43. package/test/policy.test.mjs +132 -0
  44. package/test/row-cache.test.mjs +237 -138
  45. package/test/ssh-probe.test.mjs +834 -0
  46. package/test/test-op-fastpath.test.mjs +208 -0
  47. package/test/toml.test.mjs +352 -0
  48. package/test/update.test.mjs +270 -0
  49. package/test/util.test.mjs +17 -0
  50. package/test/zero-knowledge.test.mjs +120 -78
@@ -1,352 +1,515 @@
1
- // Contract check for the hand-authored client bundle: the file must be a
2
- // lazy-CJS registration the client module loader can serve and materialize,
3
- // and it must keep the page layout contract (grouped accordion / flat tiles),
4
- // the zero-knowledge surface and the configOp state machine intact.
5
-
6
- import test from 'node:test'
7
- import assert from 'node:assert/strict'
8
- import { readFileSync } from 'node:fs'
9
- import { fileURLToPath } from 'node:url'
10
- import { dirname, join } from 'node:path'
11
-
12
- const here = dirname(fileURLToPath(import.meta.url))
13
- const source = readFileSync(join(here, '..', 'lib', 'client.js'), 'utf8')
14
-
15
- test('client bundle registers through window.__ModuleLoader__.load', () => {
16
- assert.match(source, /window\.__ModuleLoader__\.load\(\{\s*id:\s*"dsh-dbhub-live"/)
17
- })
18
-
19
- test('client bundle factory is lazy CJS with module/exports boilerplate', () => {
20
- assert.match(source, /factory:\s*\(require\)\s*=>\s*\{/)
21
- assert.match(source, /var module = \{ exports: \{\} \};/)
22
- assert.match(source, /Object\.defineProperty\(exports, Symbol\.toStringTag, \{ value: "Module" \}\);/)
23
- })
24
-
25
- test('client bundle requires only baseline platform modules', () => {
26
- const requires = [...source.matchAll(/require\(\s*"([^"]+)"\s*\)/g)].map((m) => m[1])
27
- assert.deepEqual(requires, ['react', '@deepseek-ai/dsh-client-ui-primitives'])
28
- })
29
-
30
- test('client bundle exports name/inject/apply and returns module.exports', () => {
31
- assert.match(source, /exports\.name = name;/)
32
- assert.match(source, /exports\.inject = inject;/)
33
- assert.match(source, /exports\.apply = apply;/)
34
- assert.match(source, /return module\.exports;/)
35
- // The transport is PROBED, never injected: a `settingsScope` injection is
36
- // unsatisfiable on dsh 0.2.x and keeps the whole browser half pending (the
37
- // page, the sidebar entry and the Plugins card all vanish silently).
38
- assert.match(source, /var inject = \["slots"\];/)
39
- assert.doesNotMatch(source, /var inject = \[[^\]]*settingsScope/)
40
- })
41
-
42
- test('client bundle binds its host face through the runtime ladder', () => {
43
- // 0.1.x keeps the settings namespace; every newer line reads the plugin's own
44
- // authenticated bridge instead.
45
- assert.match(source, /function legacyFace\(scope\)/)
46
- assert.match(source, /function httpFace\(\)/)
47
- assert.match(source, /var BRIDGE_BASE = "api\/dsh-dbhub-live";/)
48
- assert.match(source, /var legacyScope = typeof ctx\.get === "function" \? ctx\.get\("settingsScope"\) : undefined;/)
49
- assert.match(source, /var face = legacyScope && typeof legacyScope\.bind === "function" \? legacyFace\(legacyScope\) : httpFace\(\);/)
50
- })
51
-
52
- test('client bundle writes options through the settings form when it can', () => {
53
- // dsh 0.1.7+/0.2.x: `ctx.configForms.get(entryId).set(field, value)` — the
54
- // entry id arrives in the Host view. POST /options is the fallback (0.1.x, or
55
- // a deployment without the settings client).
56
- assert.match(source, /var saveOptions = function \(patch\) \{/)
57
- assert.match(source, /var forms = typeof ctx\.get === "function" \? ctx\.get\("configForms"\) : undefined;/)
58
- assert.match(source, /form = forms\.get\(entryId\);/)
59
- assert.match(source, /return form\.set\(key, raw\)\.then/)
60
- assert.match(source, /return post\("\/options", patch\);/)
61
- assert.match(source, /saveConfig: function \(patch\) \{ return saveOptions\(patch \|\| \{\}\); \},/)
62
- })
63
-
64
- test('client bundle registers the plugins.row.config page keyed to package#row', () => {
65
- // dsh 0.1.6 dropped settings.plugin.item; the plugin's own configuration now
66
- // lives on the Plugins page through the keyed plugins.row.config slot.
67
- assert.doesNotMatch(source, /settings\.plugin\.item/)
68
- assert.match(source, /slots\.inject\(SLOT/)
69
- assert.match(source, /var SLOT = "plugins\.row\.config";/)
70
- assert.match(source, /var SLOT_KEY = "dsh-dbhub-live#dbhub-live";/)
71
- assert.match(source, /name:\s*SLOT, key:\s*SLOT_KEY/)
72
- })
73
-
74
- test('client bundle renders both owner views (summary one-liner + page)', () => {
75
- assert.match(source, /var isPage = props\.view !== "summary";/)
76
- assert.match(source, /if \(!isPage\)/)
77
- assert.match(source, /h\("span", \{ className: "dbh-summary" \}/)
78
- })
79
-
80
- test('client bundle owns a Settings section (core shell, always available)', () => {
81
- assert.match(source, /slots\.inject\("settings\.section"/)
82
- assert.match(source, /name: "settings\.section",\s*\n\s*id: "dbhub"/)
83
- assert.match(source, /label: function \(\) \{ return t\("title"\); \}/)
84
- assert.match(source, /function ConfigPanel\(props\)/)
85
- // the settings section owns its scroll container; the Plugins row does not
86
- assert.match(source, /className: "dbh-wrap"/)
87
- })
88
-
89
- test('client bundle owns an optional sidebar entry toggled by showSidebarEntry', () => {
90
- assert.match(source, /slots\.inject\("sidebar\.panellist"/)
91
- assert.match(source, /name: "sidebar\.panellist",\s*\n\s*id: "dbhub"/)
92
- assert.match(source, /function PanelIcon\(props\)/)
93
- assert.match(source, /slots\.inject\("main"/)
94
- assert.match(source, /name: "main",\s*\n\s*key: "dbhub"/)
95
- // the switch lives on the settings page and applies live
96
- assert.match(source, /v\.showSidebarEntry !== false/)
97
- assert.match(source, /var syncSidebar = function/)
98
- assert.match(source, /face\.subscribe\(syncSidebar\)/)
99
- assert.match(source, /props\.saveConfig\(\{ showSidebarEntry: next \}\)/)
100
- })
101
-
102
- test('client bundle exposes the config editor (saveConfig + editable options)', () => {
103
- assert.match(source, /saveConfig:\s*function/)
104
- assert.match(source, /updateIntervalDays/)
105
- assert.match(source, /保存配置/)
106
- // the auto-install package is NOT a settings field anymore
107
- assert.doesNotMatch(source, /dbhubPackage/)
108
- })
109
-
110
- test('client bundle injects one scoped stylesheet built on theme tokens', () => {
111
- assert.match(source, /var STYLE_ID = "dsh-dbhub-live-style";/)
112
- assert.match(source, /function ensureStyle\(\)/)
113
- assert.match(source, /document\.getElementById\(STYLE_ID\)/)
114
- assert.match(source, /document\.createElement\("style"\)/)
115
- assert.match(source, /ensureStyle\(\);/)
116
- // tokens with neutral fallbacks so the page follows the active skin/theme
117
- assert.match(source, /--dsw-alias-bg-layer-2/)
118
- assert.match(source, /--dsw-alias-label-primary/)
119
- assert.match(source, /--dsw-alias-interactive-bg-hover/)
120
- assert.match(source, /--dsw-alias-state-error-primary/)
121
- assert.match(source, /--dsw-alias-state-success-tertiary/)
122
- assert.match(source, /--dsw-alias-border-l1/)
123
- assert.match(source, /\.dbh-btn:focus-visible/)
124
- assert.match(source, /\.dbh-grouphead:hover/)
125
- })
126
-
127
- test('client bundle decides the layout from the workspace×environment shape', () => {
128
- // Pure helpers: grouping by PATH (a title is not identity) and the
129
- // threshold-free mode decision (tiles when every workspace has one env).
130
- assert.match(source, /function groupRows\(rows\)/)
131
- assert.match(source, /function layoutModeOf\(groups\)/)
132
- assert.match(source, /if \(groups\[i\]\.envs\.length > 1\) return "grouped";/)
133
- assert.match(source, /return "tiled";/)
134
- assert.match(source, /return "empty";/)
135
- assert.match(source, /var key = String\(r\.path \|\| r\.title \|\| ""\);/)
136
- assert.match(source, /order\[j\]\.dup = titleCount\[order\[j\]\.title\] > 1;/)
137
- })
138
-
139
- test('client bundle folds workspaces per group, with click-to-expand groups', () => {
140
- assert.match(source, /var groupView = function \(g\)/)
141
- assert.match(source, /var toggleGroup = function \(g\)/)
142
- assert.match(source, /"aria-expanded": open/)
143
- assert.match(source, /className: "dbh-chev" \+ \(open \? " open" : ""\)/)
144
- assert.match(source, /t\("grp\.envs", \{ n: String\(g\.envs\.length\), m: String\(g\.saved\) \}\)/)
145
- // a lone workspace opens by itself; otherwise the most recently used one does
146
- assert.match(source, /if \(groups\.length === 1\) return true;/)
147
- assert.match(source, /function recentWorkspacePath\(snapshot\)/)
148
- assert.match(source, /return !!recentPath && samePath\(g\.path, recentPath\);/)
149
- assert.match(source, /typeof props\.useWorkspaces === "function"/)
150
- // expand/collapse-all is always available in grouped mode
151
- assert.match(source, /allOpen \? t\("btn\.collapseAll"\) : t\("btn\.expandAll"\)/)
152
- })
153
-
154
- test('client bundle tiles single-environment workspaces instead of folding them', () => {
155
- assert.match(source, /var tileView = function \(g\)/)
156
- assert.match(source, /className: "dbh-tiles"/)
157
- assert.match(source, /groups\.map\(tileView\)/)
158
- // one row per connection: env chip + 🔒 metadata + actions
159
- assert.match(source, /var envRow = function \(w\)/)
160
- assert.match(source, /className: "dbh-envrow"/)
161
- assert.match(source, /className: "dbh-conn", title: w\.conn/)
162
- // where a connection came from is identical on almost every row, so it is a
163
- // tooltip on the environment chip — never an inline badge
164
- assert.match(source, /var originTitle = function \(w, base\)/)
165
- assert.match(source, /className: "dbh-envname", title: originTitle\(w, w\.env\)/)
166
- assert.match(source, /t\("ws\.source"\) \+ ":" \+ sourceLabelOf\(w, t\)/)
167
- assert.match(source, /if \(!w\.persisted\) text \+= " " \+ t\("ws\.autoHint"\);/)
168
- assert.doesNotMatch(source, /dbh-pill/)
169
- })
170
-
171
- test('client bundle localizes copy through ctx.locale (zh/en dictionaries)', () => {
172
- assert.match(source, /localeSvc\.register\(NS, \{ zh: LOCALE_ZH, en: LOCALE_EN \}\)/)
173
- assert.match(source, /face\.t = t|t: t,/)
174
- assert.match(source, /var LOCALE_ZH = \{/)
175
- assert.match(source, /var LOCALE_EN = \{/)
176
- assert.match(source, /"conn\.title": "连接"/)
177
- assert.match(source, /"conn\.title": "Connections"/)
178
- assert.match(source, /"btn\.addEnv": "+ 环境"/)
179
- assert.match(source, /"btn\.addEnv": "+ Environment"/)
180
- assert.match(source, /"cfg\.summary":/)
181
- })
182
-
183
- test('client bundle releases its locale dictionaries on disposal (hot reload)', () => {
184
- // The locale service refuses a namespace+locale it already owns, so a bundle
185
- // reload that kept its registration alive used to abort the whole plugin half
186
- // ("locale namespace dsh-dbhub-live already has locale zh").
187
- assert.match(source, /var localeDispose = null;/)
188
- assert.match(source, /localeDispose = localeSvc\.register\(NS, \{ zh: LOCALE_ZH, en: LOCALE_EN \}\);/)
189
- assert.match(source, /ctx\.effect\(function \(\) \{ return localeDispose; \}, "dsh-dbhub-live: locale dictionaries"\);/)
190
- // ... and a duplicate must degrade to the local dictionaries, never throw.
191
- assert.match(source, /locale namespace busy, falling back to local dictionaries/)
192
- assert.match(source, /function localTranslator\(localeSvc\)/)
193
- assert.match(source, /var t = localeOwned && localeSvc \? localeSvc\.bind\(NS\) : localTranslator\(localeSvc\);/)
194
- })
195
-
196
- test('client bundle hands a copied connection label over verbatim', () => {
197
- // Copying across the split label's flex boxes makes the browser insert a
198
- // separator ("…3306/ app" when pasted into a DSN field), so the label answers
199
- // the copy event itself with the exact metadata string.
200
- assert.match(source, /var onConnCopy = function \(w, e\)/)
201
- assert.match(source, /e\.clipboardData\.setData\("text\/plain", String\(w\.conn \|\| ""\)\)/)
202
- assert.match(source, /h\("span", \{ className: "dbh-conn", title: w\.conn, onCopy: function \(e\) \{ onConnCopy\(w, e\); \} \}/)
203
- })
204
-
205
- test('client bundle cannot dispatch a second pre-test while one is pending', () => {
206
- assert.match(source, /var pendingPretest = !!\(pretest && pretest\.pendingNonce\);/)
207
- assert.match(source, /disabled: pendingPretest,\s*\n\s*onClick: addDsn,/)
208
- assert.match(source, /disabled: pendingPretest,\s*\n\s*onClick: function \(\) \{ saveEdit\(w\); \},/)
209
- })
210
-
211
- test('client bundle asks before overwriting an existing environment', () => {
212
- // Writing into (or renaming onto) an environment that already has a connection
213
- // REPLACES it — the Host's setWorkspaceEnv is an unconditional store write, so
214
- // the page must ask before dispatching anything (it used to be silent).
215
- assert.match(source, /var ENV_NAME_MAX = 64;/)
216
- assert.match(source, /function normalizeEnvForCompare\(name\)/)
217
- assert.match(source, /function envCollision\(rows, wsRef, envName\)/)
218
- assert.match(source, /var requestWrite = function \(form, check\)/)
219
- assert.match(source, /if \(hit\) \{\s*\n\s*setOverwrite\(\{ form: form, hit: hit \}\);/)
220
- assert.match(source, /var executeWrite = function \(form\)/)
221
- assert.match(source, /var confirmOverwrite = function \(\)/)
222
- assert.match(source, /var cancelOverwrite = function \(\) \{ setOverwrite\(null\); \}/)
223
- // both entry points go through the gate: the add form and a renaming edit
224
- assert.match(source, /env: addForm\.env\.trim\(\) \|\| "default",\s*\n\s*dsn: addForm\.dsn\.trim\(\),\s*\n\s*\}, true\);/)
225
- assert.match(source, /if \(renaming\) requestWrite\(\{ ws: row\.path, env: newEnv, dsn: "", _editRow: row \}, true\);/)
226
- assert.match(source, /_renameFrom: renaming \? row\.env : "" \}, renaming\);/)
227
- // editing the connection of the SAME environment is not an overwrite question
228
- assert.match(source, /var renaming = normalizeEnvForCompare\(newEnv\) !== normalizeEnvForCompare\(row\.env\);/)
229
- // the prompt is a warning card with an explicit overwrite/cancel pair
230
- assert.match(source, /className: "dbh-warncard", role: "alert"/)
231
- assert.match(source, /t\("ovr\.title"\)/)
232
- assert.match(source, /t\("ovr\.bodyAmbiguous", \{/)
233
- assert.match(source, /t\("ovr\.confirm"\)/)
234
- assert.match(source, /overwriteView \|\| pretestView,/)
235
- })
236
-
237
- test('client bundle keeps the source handle copyable instead of echoing it inline', () => {
238
- assert.match(source, /w\.srcId/)
239
- assert.match(source, /var copySource = function \(text, key\)/)
240
- // the platform clipboard helper already falls back to execCommand
241
- assert.match(source, /writeClipboard/)
242
- assert.match(source, /function shortHandle\(id\)/)
243
- assert.match(source, /var sourceChip = function \(w, key\)/)
244
- assert.match(source, /className: "dbh-src" \+ \(copied \? " dbh-src-ok" : ""\)/)
245
- assert.match(source, /t\("ws\.srcHint"\)/)
246
- assert.match(source, /h\("span", \{ className: "dbh-srcid" \}, copied \? t\("copy\.ok"\) : shortHandle\(w\.srcId\)\)/)
247
- // the handle is metadata, never a DSN
248
- assert.doesNotMatch(source, /w\.dsn/)
249
- })
250
-
251
- test('client bundle uses product icons for row actions instead of text labels', () => {
252
- assert.match(source, /function pickIcon\(name, glyph\)/)
253
- assert.match(source, /typeof Icon === "function" \? Icon : function \(\) \{ return h\("span"/)
254
- assert.match(source, /pickIcon\("IconCopyOutline16"/)
255
- assert.match(source, /pickIcon\("IconEditOutline16"/)
256
- assert.match(source, /pickIcon\("IconCloseOutline16"/)
257
- assert.match(source, /pickIcon\("IconTrashOutline16"/)
258
- assert.match(source, /pickIcon\("IconPlayOutline16"/)
259
- // icons carry their label in title/aria-label, never as row text
260
- assert.match(source, /"aria-label": t\("btn\.test"\)/)
261
- assert.match(source, /"aria-label": t\("btn\.delete"\)/)
262
- })
263
-
264
- test('client bundle keeps the database name visible when the row is narrow', () => {
265
- // `type://host:port/` may be truncated; the database name must not be.
266
- assert.match(source, /function splitConn\(conn\)/)
267
- assert.match(source, /className: "dbh-conn-pre"/)
268
- assert.match(source, /className: "dbh-conn-db"/)
269
- })
270
-
271
- test('client bundle styles stay theme-safe (tokens only, no opaque literals)', () => {
272
- // A themed or translucent skin may redefine the tokens or leave some
273
- // undefined: surfaces must inherit the page background instead of painting a
274
- // hard block, and text must never fall back to a fixed light-mode grey.
275
- assert.doesNotMatch(source, /#ffffff|#f6f8fa|#d0d7de|#1f2328|#e03131|#2f9e44/i)
276
- const start = source.indexOf('var CSS_TEXT = [')
277
- const end = source.indexOf('].join("");', start)
278
- assert.ok(start > 0 && end > start, 'CSS_TEXT block must exist')
279
- const css = source.slice(start, end)
280
- const offenders = [...css.matchAll(/([a-z-]*(?:color|background|border))\s*:\s*([^;"]+)/g)]
281
- .map((m) => `${m[1]}:${m[2]}`)
282
- .filter((decl) => {
283
- const value = decl.slice(decl.indexOf(':') + 1)
284
- if (value.includes('var(--dsw-alias-')) return false
285
- // a literal colour is the problem; `transparent`/`none`/lengths are fine
286
- return /#[0-9a-f]{3,8}|(?:rgba?|hsla?)\(|\b(?:white|black|gray|grey|silver)\b/i.test(value)
287
- })
288
- assert.deepEqual(offenders, [])
289
- // the only literal left is the (theme-neutral) hairline card shadow
290
- assert.match(css, /box-shadow:0 1px 2px rgba\(0,0,0,\.05\)/)
291
- })
292
-
293
- test('client bundle manages workspace connections through configOp', () => {
294
- assert.match(source, /configOp:\s*function/)
295
- assert.match(source, /op: "add"/)
296
- assert.match(source, /op: "remove"/)
297
- assert.match(source, /op: "rename"/)
298
- assert.match(source, /newEnv/)
299
- assert.match(source, /renameFrom/)
300
- assert.match(source, /workspacesOf\(value\)/)
301
- assert.match(source, /已保存|自动/)
302
- })
303
-
304
- test('client bundle folds the add form behind an explicit entry point', () => {
305
- assert.match(source, /var openAdd = function \(target\)/)
306
- assert.match(source, /var closeAdd = function \(\)/)
307
- assert.match(source, /t\("btn\.addConn"\)/)
308
- assert.match(source, /onClick: function \(\) \{ openAdd\(g\.path\); \}/)
309
- assert.match(source, /className: "dbh-addpanel"/)
310
- // per-workspace adds carry the workspace implicitly
311
- assert.match(source, /ws: addTarget \|\| addForm\.ws\.trim\(\)/)
312
- assert.match(source, /id: "dbh-ws-list"/)
313
- })
314
-
315
- test('client bundle folds status/options into the header + a settings strip', () => {
316
- assert.match(source, /className: "dbh-statustext", title: modeLabel/)
317
- assert.match(source, /var toggleSettings = function \(\)/)
318
- assert.match(source, /className: "dbh-strip"/)
319
- assert.match(source, /t\("cfg\.summary", \{/)
320
- assert.match(source, /className: "dbh-cfgsum"/)
321
- // view state survives remounts within the page session
322
- assert.match(source, /var uiMemory = \{ expanded: \{\}, addOpen: false, addTarget: "", settingsOpen: false \};/)
323
- })
324
-
325
- test('client bundle offers a transient per-row connection test', () => {
326
- assert.match(source, /op: "test"/)
327
- assert.match(source, /nonce/)
328
- assert.match(source, /value\.testResult/)
329
- // results are surfaced only for nonces the card itself dispatched
330
- assert.match(source, /nonceKeys/)
331
- assert.match(source, /btn\.test/)
332
- })
333
-
334
- test('client bundle keeps the pre-test gate before saving a connection', () => {
335
- assert.match(source, /var dispatchPretest = function \(form\)/)
336
- assert.match(source, /"pretest:" \+ \(form\.ws \|\| ""\)/)
337
- assert.match(source, /var confirmSave = function \(\)/)
338
- assert.match(source, /t\("pretest\.failHint"\)/)
339
- assert.match(source, /className: "dbh-pretest"/)
340
- })
341
-
342
- test('client bundle injects only slots and probes the host transport', () => {
343
- // Regression gate for the 0.2.x failure: a never-satisfied `settingsScope`
344
- // injection left the plugin pending forever, so nothing rendered and nothing
345
- // logged. Only `slots` may be injected; the face probes for the rest.
346
- assert.match(source, /var inject = \["slots"\];/)
347
- assert.doesNotMatch(source, /inject = \[[^\]]*"settingsScope"/)
348
- // The live poll belongs to a rendered page, not to the plugin scope: an idle
349
- // GUI must not poll the bridge.
350
- assert.match(source, /props\.subscribe\(function \(\) \{ setValue\(valueOf\(props\.getSnapshot\(\)\)\); \}, \{ live: true \}\);/)
351
- assert.match(source, /var live = !!\(opts && opts\.live\);/)
352
- })
1
+ // Contract check for the hand-authored client bundle: the file must be a
2
+ // lazy-CJS registration the client module loader can serve and materialize,
3
+ // and it must keep the page layout contract (grouped accordion / flat tiles),
4
+ // the zero-knowledge surface and the configOp state machine intact.
5
+
6
+ import test from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { readFileSync } from 'node:fs'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { dirname, join } from 'node:path'
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url))
13
+ const source = readFileSync(join(here, '..', 'lib', 'client.js'), 'utf8')
14
+
15
+ test('client bundle registers through window.__ModuleLoader__.load', () => {
16
+ assert.match(source, /window\.__ModuleLoader__\.load\(\{\s*id:\s*"dsh-dbhub-live"/)
17
+ })
18
+
19
+ test('client bundle factory is lazy CJS with module/exports boilerplate', () => {
20
+ assert.match(source, /factory:\s*\(require\)\s*=>\s*\{/)
21
+ assert.match(source, /var module = \{ exports: \{\} \};/)
22
+ assert.match(source, /Object\.defineProperty\(exports, Symbol\.toStringTag, \{ value: "Module" \}\);/)
23
+ })
24
+
25
+ test('client bundle requires only baseline platform modules', () => {
26
+ const requires = [...source.matchAll(/require\(\s*"([^"]+)"\s*\)/g)].map((m) => m[1])
27
+ assert.deepEqual(requires, ['react', '@deepseek-ai/dsh-client-ui-primitives'])
28
+ })
29
+
30
+ test('client bundle exports name/inject/apply and returns module.exports', () => {
31
+ assert.match(source, /exports\.name = name;/)
32
+ assert.match(source, /exports\.inject = inject;/)
33
+ assert.match(source, /exports\.apply = apply;/)
34
+ assert.match(source, /return module\.exports;/)
35
+ // The transport is PROBED, never injected: a `settingsScope` injection is
36
+ // unsatisfiable on dsh 0.2.x and keeps the whole browser half pending (the
37
+ // page, the sidebar entry and the Plugins card all vanish silently).
38
+ assert.match(source, /var inject = \["slots"\];/)
39
+ assert.doesNotMatch(source, /var inject = \[[^\]]*settingsScope/)
40
+ })
41
+
42
+ test('client bundle binds its host face through the runtime ladder', () => {
43
+ // 0.1.x keeps the settings namespace; every newer line reads the plugin's own
44
+ // authenticated bridge instead.
45
+ assert.match(source, /function legacyFace\(scope\)/)
46
+ assert.match(source, /function httpFace\(\)/)
47
+ assert.match(source, /var BRIDGE_BASE = "api\/dsh-dbhub-live";/)
48
+ assert.match(source, /var legacyScope = typeof ctx\.get === "function" \? ctx\.get\("settingsScope"\) : undefined;/)
49
+ assert.match(source, /var face = legacyScope && typeof legacyScope\.bind === "function" \? legacyFace\(legacyScope\) : httpFace\(\);/)
50
+ })
51
+
52
+ test('client bundle writes options through the settings form when it can', () => {
53
+ // dsh 0.1.7+/0.2.x: `ctx.configForms.get(entryId).set(field, value)` — the
54
+ // entry id arrives in the Host view. POST /options is the fallback (0.1.x, or
55
+ // a deployment without the settings client).
56
+ assert.match(source, /var saveOptions = function \(patch\) \{/)
57
+ assert.match(source, /var forms = typeof ctx\.get === "function" \? ctx\.get\("configForms"\) : undefined;/)
58
+ assert.match(source, /form = forms\.get\(entryId\);/)
59
+ assert.match(source, /return form\.set\(key, raw\)\.then/)
60
+ assert.match(source, /return post\("\/options", patch\);/)
61
+ assert.match(source, /saveConfig: function \(patch\) \{ return saveOptions\(patch \|\| \{\}\); \},/)
62
+ })
63
+
64
+ test('client bundle registers the plugins.row.config page keyed to package#row', () => {
65
+ // dsh 0.1.6 dropped settings.plugin.item; the plugin's own configuration now
66
+ // lives on the Plugins page through the keyed plugins.row.config slot.
67
+ assert.doesNotMatch(source, /settings\.plugin\.item/)
68
+ assert.match(source, /slots\.inject\(SLOT/)
69
+ assert.match(source, /var SLOT = "plugins\.row\.config";/)
70
+ assert.match(source, /var SLOT_KEY = "dsh-dbhub-live#dbhub-live";/)
71
+ assert.match(source, /name:\s*SLOT, key:\s*SLOT_KEY/)
72
+ })
73
+
74
+ test('client bundle renders both owner views (summary one-liner + page)', () => {
75
+ assert.match(source, /var isPage = props\.view !== "summary";/)
76
+ assert.match(source, /if \(!isPage\)/)
77
+ assert.match(source, /h\("span", \{ className: "dbh-summary" \}/)
78
+ })
79
+
80
+ test('client bundle owns a Settings section (core shell, always available)', () => {
81
+ assert.match(source, /slots\.inject\("settings\.section"/)
82
+ assert.match(source, /name: "settings\.section",\s*\n\s*id: "dbhub"/)
83
+ assert.match(source, /label: function \(\) \{ return t\("title"\); \}/)
84
+ assert.match(source, /function ConfigPanel\(props\)/)
85
+ // the settings section owns its scroll container; the Plugins row does not
86
+ assert.match(source, /className: "dbh-wrap"/)
87
+ })
88
+
89
+ test('client bundle owns a sidebar entry gated by enabled AND the user switch', () => {
90
+ assert.match(source, /slots\.inject\("sidebar\.panellist"/)
91
+ assert.match(source, /name: "sidebar\.panellist",\s*\n\s*id: "dbhub"/)
92
+ assert.match(source, /function PanelIcon\(props\)/)
93
+ assert.match(source, /slots\.inject\("main"/)
94
+ assert.match(source, /name: "main",\s*\n\s*key: "dbhub"/)
95
+ // Two independent conditions: the user's "hide the shortcut" switch and the
96
+ // plugin's enabled state. Hiding the entry is NOT disabling the plugin.
97
+ assert.match(source, /return v\.enabled !== false && v\.showSidebarEntry !== false;/)
98
+ assert.match(source, /var syncSidebar = function/)
99
+ assert.match(source, /face\.subscribe\(syncSidebar\)/)
100
+ // The switch itself is back on the settings strip and applies immediately.
101
+ assert.match(source, /"cfg\.sidebar": "在侧边栏显示入口"/)
102
+ assert.match(source, /"cfg\.sidebar": "Show the sidebar entry"/)
103
+ assert.match(source, /var toggleSidebar = function \(next\)/)
104
+ assert.match(source, /props\.saveConfig\(\{ showSidebarEntry: next \}\);/)
105
+ assert.match(source, /showSidebarEntry: draft\.showSidebarEntry !== false,/)
106
+ // The settings section and the Plugins row never depend on either condition, so
107
+ // no combination can lock the user out of the configuration.
108
+ assert.match(source, /slots\.inject\("settings\.section"/)
109
+ assert.match(source, /"cfg\.disabledHint":/)
110
+ assert.match(source, /t\("cfg\.enabledHint"\) : t\("cfg\.disabledHint"\)\) \+ " " \+ t\("cfg\.sidebarHint"\)/)
111
+ })
112
+
113
+ test('client bundle exposes the config editor (saveConfig + editable options)', () => {
114
+ assert.match(source, /saveConfig:\s*function/)
115
+ assert.match(source, /updateIntervalDays/)
116
+ assert.match(source, /保存配置/)
117
+ // the auto-install package is NOT a settings field anymore
118
+ assert.doesNotMatch(source, /dbhubPackage/)
119
+ })
120
+
121
+ test('client bundle injects one scoped stylesheet built on theme tokens', () => {
122
+ assert.match(source, /var STYLE_ID = "dsh-dbhub-live-style";/)
123
+ assert.match(source, /function ensureStyle\(\)/)
124
+ assert.match(source, /document\.getElementById\(STYLE_ID\)/)
125
+ assert.match(source, /document\.createElement\("style"\)/)
126
+ assert.match(source, /ensureStyle\(\);/)
127
+ // tokens with neutral fallbacks so the page follows the active skin/theme
128
+ assert.match(source, /--dsw-alias-bg-layer-2/)
129
+ assert.match(source, /--dsw-alias-label-primary/)
130
+ assert.match(source, /--dsw-alias-interactive-bg-hover/)
131
+ assert.match(source, /--dsw-alias-state-error-primary/)
132
+ assert.match(source, /--dsw-alias-state-success-tertiary/)
133
+ assert.match(source, /--dsw-alias-border-l1/)
134
+ assert.match(source, /\.dbh-btn:focus-visible/)
135
+ assert.match(source, /\.dbh-grouphead:hover/)
136
+ })
137
+
138
+ test('client bundle decides the layout from the workspace×environment shape', () => {
139
+ // Pure helpers: grouping by PATH (a title is not identity) and the
140
+ // threshold-free mode decision (tiles when every workspace has one env).
141
+ assert.match(source, /function groupRows\(rows\)/)
142
+ assert.match(source, /function layoutModeOf\(groups\)/)
143
+ assert.match(source, /if \(groups\[i\]\.envs\.length > 1\) return "grouped";/)
144
+ assert.match(source, /return "tiled";/)
145
+ assert.match(source, /return "empty";/)
146
+ assert.match(source, /var key = String\(r\.path \|\| r\.title \|\| ""\);/)
147
+ assert.match(source, /order\[j\]\.dup = titleCount\[order\[j\]\.title\] > 1;/)
148
+ })
149
+
150
+ test('client bundle folds workspaces per group, with click-to-expand groups', () => {
151
+ assert.match(source, /var groupView = function \(g\)/)
152
+ assert.match(source, /var toggleGroup = function \(g\)/)
153
+ assert.match(source, /"aria-expanded": open/)
154
+ assert.match(source, /className: "dbh-chev" \+ \(open \? " open" : ""\)/)
155
+ assert.match(source, /t\("grp\.envs", \{ n: String\(g\.envs\.length\), m: String\(g\.saved\) \}\)/)
156
+ // a lone workspace opens by itself; otherwise the most recently used one does
157
+ assert.match(source, /if \(groups\.length === 1\) return true;/)
158
+ assert.match(source, /function recentWorkspacePath\(snapshot\)/)
159
+ assert.match(source, /return !!recentPath && samePath\(g\.path, recentPath\);/)
160
+ assert.match(source, /typeof props\.useWorkspaces === "function"/)
161
+ // expand/collapse-all is always available in grouped mode
162
+ assert.match(source, /allOpen \? t\("btn\.collapseAll"\) : t\("btn\.expandAll"\)/)
163
+ })
164
+
165
+ test('client bundle tiles single-environment workspaces instead of folding them', () => {
166
+ assert.match(source, /var tileView = function \(g\)/)
167
+ assert.match(source, /className: "dbh-tiles"/)
168
+ assert.match(source, /groups\.map\(tileView\)/)
169
+ // one row per connection: env chip + 🔒 metadata + actions
170
+ assert.match(source, /var envRow = function \(w\)/)
171
+ assert.match(source, /className: "dbh-envrow"/)
172
+ assert.match(source, /className: "dbh-conn", title: w\.conn/)
173
+ // where a connection came from is identical on almost every row, so it is a
174
+ // tooltip on the environment chip — never an inline badge
175
+ assert.match(source, /var originTitle = function \(w, base\)/)
176
+ assert.match(source, /title: originTitle\(w, w\.env\)/)
177
+ assert.match(source, /t\("ws\.source"\) \+ ":" \+ sourceLabelOf\(w, t\)/)
178
+ assert.match(source, /if \(!w\.persisted\) text \+= " " \+ t\("ws\.autoHint"\);/)
179
+ // the read-only flag is a per-row ATTRIBUTE that changes what a query may do,
180
+ // so it is visible on the chip (the source is a tooltip, this is not)
181
+ assert.match(source, /var envChip = function \(w\)/)
182
+ assert.match(source, /className: "dbh-ro", title: t\("ro\.title"\)/)
183
+ assert.match(source, /if \(w\.ro === true\) text \+= " · " \+ t\("ro\.title"\);/)
184
+ assert.match(source, /if \(w\.ssh\) text \+= " · " \+ t\("ro\.sshTitle"/)
185
+ assert.doesNotMatch(source, /dbh-pill/)
186
+ })
187
+
188
+ test('client bundle localizes copy through ctx.locale (zh/en dictionaries)', () => {
189
+ assert.match(source, /localeSvc\.register\(NS, \{ zh: LOCALE_ZH, en: LOCALE_EN \}\)/)
190
+ assert.match(source, /face\.t = t|t: t,/)
191
+ assert.match(source, /var LOCALE_ZH = \{/)
192
+ assert.match(source, /var LOCALE_EN = \{/)
193
+ assert.match(source, /"conn\.title": "连接"/)
194
+ assert.match(source, /"conn\.title": "Connections"/)
195
+ assert.match(source, /"btn\.addEnv": "+ 环境"/)
196
+ assert.match(source, /"btn\.addEnv": "+ Environment"/)
197
+ assert.match(source, /"cfg\.summary":/)
198
+ })
199
+
200
+ test('client bundle releases its locale dictionaries on disposal (hot reload)', () => {
201
+ // The locale service refuses a namespace+locale it already owns, so a bundle
202
+ // reload that kept its registration alive used to abort the whole plugin half
203
+ // ("locale namespace dsh-dbhub-live already has locale zh").
204
+ assert.match(source, /var localeDispose = null;/)
205
+ assert.match(source, /localeDispose = localeSvc\.register\(NS, \{ zh: LOCALE_ZH, en: LOCALE_EN \}\);/)
206
+ assert.match(source, /ctx\.effect\(function \(\) \{ return localeDispose; \}, "dsh-dbhub-live: locale dictionaries"\);/)
207
+ // ... and a duplicate must degrade to the local dictionaries, never throw.
208
+ assert.match(source, /locale namespace busy, falling back to local dictionaries/)
209
+ assert.match(source, /function localTranslator\(localeSvc\)/)
210
+ assert.match(source, /var t = localeOwned && localeSvc \? localeSvc\.bind\(NS\) : localTranslator\(localeSvc\);/)
211
+ })
212
+
213
+ test('client bundle hands a copied connection label over verbatim', () => {
214
+ // Copying across the split label's flex boxes makes the browser insert a
215
+ // separator ("…3306/ app" when pasted into a DSN field), so the label answers
216
+ // the copy event itself with the exact metadata string.
217
+ assert.match(source, /var onConnCopy = function \(w, e\)/)
218
+ assert.match(source, /e\.clipboardData\.setData\("text\/plain", String\(w\.conn \|\| ""\)\)/)
219
+ assert.match(source, /h\("span", \{ className: "dbh-conn", title: w\.conn, onCopy: function \(e\) \{ onConnCopy\(w, e\); \} \}/)
220
+ })
221
+
222
+ test('client bundle cannot dispatch a second pre-test while one is pending', () => {
223
+ assert.match(source, /var pendingPretest = !!\(pretest && pretest\.pendingNonce\);/)
224
+ assert.match(source, /disabled: pendingPretest,\s*\n\s*onClick: addDsn,/)
225
+ assert.match(source, /disabled: pendingPretest,\s*\n\s*onClick: function \(\) \{ saveEdit\(w\); \},/)
226
+ })
227
+
228
+ test('client bundle asks before overwriting an existing environment', () => {
229
+ // Writing into (or renaming onto) an environment that already has a connection
230
+ // REPLACES it — the Host's setWorkspaceEnv is an unconditional store write, so
231
+ // the page must ask before dispatching anything (it used to be silent).
232
+ assert.match(source, /var ENV_NAME_MAX = 64;/)
233
+ assert.match(source, /function normalizeEnvForCompare\(name\)/)
234
+ assert.match(source, /function envCollision\(rows, wsRef, envName\)/)
235
+ assert.match(source, /var requestWrite = function \(form, check\)/)
236
+ assert.match(source, /if \(hit\) \{\s*\n\s*setOverwrite\(\{ form: form, hit: hit \}\);/)
237
+ assert.match(source, /var executeWrite = function \(form\)/)
238
+ assert.match(source, /var confirmOverwrite = function \(\)/)
239
+ assert.match(source, /var cancelOverwrite = function \(\) \{ setOverwrite\(null\); \}/)
240
+ // both entry points go through the gate: the add form and a renaming edit
241
+ assert.match(source, /function connDraftOf\(form\)/)
242
+ assert.match(source, /requestWrite\(withConnDraft\(\{\s*\n\s*ws: addTarget \|\| addForm\.ws\.trim\(\),\s*\n\s*env: addForm\.env\.trim\(\) \|\| "default",\s*\n\s*dsn: addForm\.dsn\.trim\(\),\s*\n\s*_draft: addForm,\s*\n\s*\}, addForm\), true\);/)
243
+ assert.match(source, /_renameFrom: renaming \? row\.env : "" \}, editing\), renaming\);/)
244
+ // editing the connection of the SAME environment is not an overwrite question
245
+ assert.match(source, /var renaming = normalizeEnvForCompare\(newEnv\) !== normalizeEnvForCompare\(row\.env\);/)
246
+ // the prompt is a warning card with an explicit overwrite/cancel pair
247
+ assert.match(source, /className: "dbh-warncard", role: "alert"/)
248
+ assert.match(source, /t\("ovr\.title"\)/)
249
+ assert.match(source, /t\("ovr\.bodyAmbiguous", \{/)
250
+ assert.match(source, /t\("ovr\.confirm"\)/)
251
+ assert.match(source, /overwriteView \|\| pretestView,/)
252
+ })
253
+
254
+ test('client bundle keeps the source handle copyable instead of echoing it inline', () => {
255
+ assert.match(source, /w\.srcId/)
256
+ assert.match(source, /var copySource = function \(text, key\)/)
257
+ // the platform clipboard helper already falls back to execCommand
258
+ assert.match(source, /writeClipboard/)
259
+ assert.match(source, /function shortHandle\(id\)/)
260
+ assert.match(source, /var sourceChip = function \(w, key\)/)
261
+ assert.match(source, /className: "dbh-src" \+ \(copied \? " dbh-src-ok" : ""\)/)
262
+ assert.match(source, /t\("ws\.srcHint"\)/)
263
+ assert.match(source, /h\("span", \{ className: "dbh-srcid" \}, copied \? t\("copy\.ok"\) : shortHandle\(w\.srcId\)\)/)
264
+ // the handle is metadata, never a DSN
265
+ assert.doesNotMatch(source, /w\.dsn/)
266
+ })
267
+
268
+ test('client bundle draws clear row-action icons instead of text labels', () => {
269
+ assert.match(source, /function pickIcon\(name, glyph\)/)
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\(/)
276
+ assert.match(source, /pickIcon\("IconCopyOutline16"/)
277
+ assert.match(source, /pickIcon\("IconCloseOutline16"/)
278
+ // icons carry their label in title/aria-label, never as row text
279
+ assert.match(source, /"aria-label": t\("btn\.test"\)/)
280
+ assert.match(source, /"aria-label": t\("btn\.delete"\)/)
281
+ })
282
+
283
+ test('client bundle keeps the database name visible when the row is narrow', () => {
284
+ // `type://host:port/` may be truncated; the database name must not be.
285
+ assert.match(source, /function splitConn\(conn\)/)
286
+ assert.match(source, /className: "dbh-conn-pre"/)
287
+ assert.match(source, /className: "dbh-conn-db"/)
288
+ })
289
+
290
+ test('client bundle styles stay theme-safe (tokens only, no opaque literals)', () => {
291
+ // A themed or translucent skin may redefine the tokens or leave some
292
+ // undefined: surfaces must inherit the page background instead of painting a
293
+ // hard block, and text must never fall back to a fixed light-mode grey.
294
+ assert.doesNotMatch(source, /#ffffff|#f6f8fa|#d0d7de|#1f2328|#e03131|#2f9e44/i)
295
+ const start = source.indexOf('var CSS_TEXT = [')
296
+ const end = source.indexOf('].join("");', start)
297
+ assert.ok(start > 0 && end > start, 'CSS_TEXT block must exist')
298
+ const css = source.slice(start, end)
299
+ const offenders = [...css.matchAll(/([a-z-]*(?:color|background|border))\s*:\s*([^;"]+)/g)]
300
+ .map((m) => `${m[1]}:${m[2]}`)
301
+ .filter((decl) => {
302
+ const value = decl.slice(decl.indexOf(':') + 1)
303
+ if (value.includes('var(--dsw-alias-')) return false
304
+ // a literal colour is the problem; `transparent`/`none`/lengths are fine
305
+ return /#[0-9a-f]{3,8}|(?:rgba?|hsla?)\(|\b(?:white|black|gray|grey|silver)\b/i.test(value)
306
+ })
307
+ assert.deepEqual(offenders, [])
308
+ // the only literal left is the (theme-neutral) hairline card shadow
309
+ assert.match(css, /box-shadow:0 1px 2px rgba\(0,0,0,\.05\)/)
310
+ })
311
+
312
+ test('client bundle manages workspace connections through configOp', () => {
313
+ assert.match(source, /configOp:\s*function/)
314
+ assert.match(source, /op: "add"/)
315
+ assert.match(source, /op: "remove"/)
316
+ assert.match(source, /op: "rename"/)
317
+ // the options-only write (read-only switch / SSH tunnel) never carries a DSN
318
+ assert.match(source, /op: "options"/)
319
+ assert.match(source, /_opKind: "rename"/)
320
+ assert.match(source, /_opKind: "options"/)
321
+ assert.match(source, /newEnv/)
322
+ assert.match(source, /renameFrom/)
323
+ assert.match(source, /workspacesOf\(value\)/)
324
+ assert.match(source, /已保存|自动/)
325
+ })
326
+
327
+ test('client bundle folds the add form behind an explicit entry point', () => {
328
+ assert.match(source, /var openAdd = function \(target\)/)
329
+ assert.match(source, /var closeAdd = function \(\)/)
330
+ assert.match(source, /t\("btn\.addConn"\)/)
331
+ assert.match(source, /onClick: function \(\) \{ openAdd\(g\.path\); \}/)
332
+ assert.match(source, /className: "dbh-addpanel"/)
333
+ // per-workspace adds carry the workspace implicitly
334
+ assert.match(source, /ws: addTarget \|\| addForm\.ws\.trim\(\)/)
335
+ assert.match(source, /id: "dbh-ws-list"/)
336
+ })
337
+
338
+ test('client bundle folds status/options into the header + a settings strip', () => {
339
+ assert.match(source, /className: "dbh-statustext", title: modeLabel/)
340
+ assert.match(source, /var toggleSettings = function \(\)/)
341
+ assert.match(source, /className: "dbh-strip"/)
342
+ assert.match(source, /t\("cfg\.summary", \{/)
343
+ assert.match(source, /className: "dbh-cfgsum"/)
344
+ // view state survives remounts within the page session
345
+ assert.match(source, /var uiMemory = \{ expanded: \{\}, addOpen: false, addTarget: "", settingsOpen: false, advOpen: false \};/)
346
+ })
347
+
348
+ test('client bundle offers a transient per-row connection test', () => {
349
+ assert.match(source, /op: "test"/)
350
+ assert.match(source, /nonce/)
351
+ assert.match(source, /value\.testResult/)
352
+ // results are surfaced only for nonces the card itself dispatched
353
+ assert.match(source, /nonceKeys/)
354
+ assert.match(source, /btn\.test/)
355
+ })
356
+
357
+ test('client bundle keeps the pre-test gate before saving a connection', () => {
358
+ assert.match(source, /var dispatchPretest = function \(form\)/)
359
+ assert.match(source, /"pretest:" \+ \(form\.ws \|\| ""\)/)
360
+ assert.match(source, /var confirmSave = function \(\)/)
361
+ assert.match(source, /t\("pretest\.failHint"\)/)
362
+ assert.match(source, /className: "dbh-pretest"/)
363
+ })
364
+
365
+ test('client bundle injects only slots and probes the host transport', () => {
366
+ // Regression gate for the 0.2.x failure: a never-satisfied `settingsScope`
367
+ // injection left the plugin pending forever, so nothing rendered and nothing
368
+ // logged. Only `slots` may be injected; the face probes for the rest.
369
+ assert.match(source, /var inject = \["slots"\];/)
370
+ assert.doesNotMatch(source, /inject = \[[^\]]*"settingsScope"/)
371
+ // The live poll belongs to a rendered page, not to the plugin scope: an idle
372
+ // GUI must not poll the bridge.
373
+ assert.match(source, /props\.subscribe\(function \(\) \{ setValue\(valueOf\(props\.getSnapshot\(\)\)\); \}, \{ live: true \}\);/)
374
+ assert.match(source, /var live = !!\(opts && opts\.live\);/)
375
+ })
376
+
377
+ test('client bundle offers the environment read-only switch and never echoes the mirrored secret', () => {
378
+ assert.match(source, /"add\.readOnly": "只读模式"/)
379
+ assert.match(source, /"add\.readOnly": "Read-only mode"/)
380
+ assert.match(source, /var roCheck = function \(form, update, keyPrefix\)/)
381
+ // the checkbox is NOT a `.dbh-field`/`.dbh-input`: the add panel's field count
382
+ // and its 0..2 input indices are pinned by the offscreen gate
383
+ assert.match(source, /type: "checkbox", className: "dbh-checkbox"/)
384
+ assert.match(source, /"aria-label": t\("add\.readOnly"\)/)
385
+ // the very same draft builder feeds every op payload
386
+ assert.match(source, /function withConnDraft\(op, form\)/)
387
+ assert.match(source, /op\.readOnly = d\.readOnly === true;/)
388
+ })
389
+
390
+ test('client bundle configures the SSH tunnel, keeping secrets write-only', () => {
391
+ assert.match(source, /"add\.adv": "高级:SSH 隧道"/)
392
+ assert.match(source, /"add\.adv": "Advanced: SSH tunnel"/)
393
+ assert.match(source, /var advPanel = function \(form, update, keyPrefix, targetOf\)/)
394
+ // the SSH-layer-only test lives in the advanced section, with its own payload
395
+ assert.match(source, /"btn\.testSsh": "测试 SSH 隧道"/)
396
+ assert.match(source, /var startSshTest = function \(form, targetOf, key\)/)
397
+ assert.match(source, /op: "test", kind: "ssh",/)
398
+ assert.match(source, /"aria-expanded": open/)
399
+ // secrets are typed in and posted, never rendered back
400
+ assert.match(source, /"adv\.keepSecret"/)
401
+ assert.match(source, /type: "password", placeholder: t\("adv\.keepSecret"\)/)
402
+ // a blank secret means "keep the stored one": the builder only attaches the
403
+ // fields the user actually typed
404
+ assert.match(source, /if \(password\) ssh\.password/)
405
+ assert.match(source, /if \(keyPath\) ssh\.keyPath/)
406
+ assert.match(source, /if \(passphrase\) ssh\.passphrase/)
407
+ // the mirrored row only carries booleans for secrets
408
+ assert.match(source, /function sshDraftOf\(row\)/)
409
+ assert.match(source, /sshKeyPath: "",\s*\n\s*sshPassword: "",\s*\n\s*sshPassphrase: "",/)
410
+ })
411
+
412
+ test('client bundle shows the elapsed time of a running probe and cleans the ticker up', () => {
413
+ assert.match(source, /"test\.elapsed": "测试中… \{s\}s"/)
414
+ assert.match(source, /startedAt: Date\.now\(\)/)
415
+ assert.match(source, /t\("test\.elapsed", \{ s: String\(secs\) \}\)/)
416
+ // the 1s ticker exists ONLY while a row is testing, and its effect cleans up
417
+ // (the offscreen gate hangs on a leaked interval, which is the point)
418
+ assert.match(source, /if \(!anyTesting \|\| typeof setInterval !== "function"\) return undefined;/)
419
+ assert.match(source, /return function \(\) \{ clearInterval\(id\); \};/)
420
+ // probe watchdogs are tiered for the slower tunnel
421
+ assert.match(source, /var PRETEST_WATCHDOG_MS = 30000;/)
422
+ assert.match(source, /var PRETEST_WATCHDOG_SSH_MS = 60000;/)
423
+ assert.match(source, /var TEST_WATCHDOG_MS = 30000;/)
424
+ assert.match(source, /var TEST_WATCHDOG_SSH_MS = 60000;/)
425
+ })
426
+
427
+ test('client bundle disables what the installed dbhub cannot do', () => {
428
+ assert.match(source, /function capsOf\(value\)/)
429
+ assert.match(source, /function capabilityHint\(caps, t\)/)
430
+ assert.match(source, /"cap\.tooOld":/)
431
+ assert.match(source, /disabled: !sshSupported,/)
432
+ assert.match(source, /disabled: !roSupported,/)
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
+ })