dsh-zen-remote 1.1.16 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- package/src/virtual-id.ts +59 -0
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
// Behaviour check for the settings block's pure logic
|
|
2
|
+
// (src/client-data/settings-form.ts), driven against the REAL admin/status
|
|
3
|
+
// shapes src/admin-routes.ts answers: devices and the pairing code live
|
|
4
|
+
// NESTED in `gateway` (the gateway's /lan-gate/status verbatim), and
|
|
5
|
+
// `gatewayStatus` is the HTTP status the route saw (null = no answer).
|
|
6
|
+
// Covers the status -> view mapping, the staged row form against a FAKE
|
|
7
|
+
// shared form object (validation, env locking, the effective-value display
|
|
8
|
+
// baseline, set/unset plans, the expectedRevision fence), the T16 client
|
|
9
|
+
// group's pure pieces: the client status -> view mapping, the pairing-code
|
|
10
|
+
// input normalization, the direct pairing writes and the latest-wins gate,
|
|
11
|
+
// the T16-fix 3 role/poll decisions extended by T17's two-level role (saved
|
|
12
|
+
// row role first, the client-config probe as fallback), and T17's
|
|
13
|
+
// restartPending flag behind the plugin-reload note (T17b: it fires only
|
|
14
|
+
// when a save would actually write a restart-field change, never for an
|
|
15
|
+
// identical draft, an invalid draft or an env-locked field).
|
|
16
|
+
//
|
|
17
|
+
// Run: node scripts/check-settings-form.mjs (needs Node >= 23.6 type stripping)
|
|
18
|
+
import assert from 'node:assert/strict'
|
|
19
|
+
import {
|
|
20
|
+
CLIENT_RECONNECT_ROUTE,
|
|
21
|
+
claimDeviceNameOf,
|
|
22
|
+
shouldFollowServerDeviceName,
|
|
23
|
+
createLatestGate,
|
|
24
|
+
clientStatusLineOf,
|
|
25
|
+
deriveClientStatusView,
|
|
26
|
+
deriveSettingsView,
|
|
27
|
+
localOpsAllowed,
|
|
28
|
+
normalizePairingCode,
|
|
29
|
+
savedRowRole,
|
|
30
|
+
settingsPollOf,
|
|
31
|
+
ZenRemoteSettingsForm,
|
|
32
|
+
} from '../src/client-data/settings-form.ts'
|
|
33
|
+
|
|
34
|
+
// Tiny sequential runner so the file stays plain node:assert (no node:test import).
|
|
35
|
+
let testChain = Promise.resolve()
|
|
36
|
+
function test(name, fn) {
|
|
37
|
+
testChain = testChain
|
|
38
|
+
.then(fn)
|
|
39
|
+
.then(() => { console.log(`ok - ${name}`) })
|
|
40
|
+
.catch((error) => {
|
|
41
|
+
console.error(`not ok - ${name}`)
|
|
42
|
+
throw error
|
|
43
|
+
})
|
|
44
|
+
return testChain
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- 1. deriveSettingsView -------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const NOW = 1_700_000_000_000
|
|
50
|
+
|
|
51
|
+
const GATEWAY = {
|
|
52
|
+
state: 'running',
|
|
53
|
+
port: 3088,
|
|
54
|
+
target: '127.0.0.1:3080',
|
|
55
|
+
upstreamAuth: 'token',
|
|
56
|
+
pwa: true,
|
|
57
|
+
devices: [
|
|
58
|
+
{ id: 'd1', name: 'iPhone', role: 'web', kind: 'phone', createdAt: NOW - 99, lastSeen: NOW - 1000, hasPush: true },
|
|
59
|
+
{ id: 'd2', name: 'Studio', role: 'desktop-client', kind: 'weird', createdAt: NOW - 99, lastSeen: NOW - 2000, hasPush: false },
|
|
60
|
+
{ id: '', name: 'ghost', role: 'web', kind: 'auto', lastSeen: 0, hasPush: false },
|
|
61
|
+
],
|
|
62
|
+
pairing: { code: 'AB12CD34', expiresAt: NOW + 6500, role: 'web' },
|
|
63
|
+
pushSubscriptions: 2,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const VALUES = {
|
|
67
|
+
role: 'host', port: 4000, host: '0.0.0.0', trustedProxies: '', rateLimit: 60,
|
|
68
|
+
vapidSubject: 'mailto:admin@example.net', lang: 'zh', pushSummary: true,
|
|
69
|
+
pushTurnEnd: false, pushTool: true, pushDebounceMs: 5000, serverName: 'studio',
|
|
70
|
+
idleHours: 24, autoShareNewSessions: false,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SOURCES = {
|
|
74
|
+
role: 'row', port: 'file', host: 'row', trustedProxies: 'default', rateLimit: 'env',
|
|
75
|
+
vapidSubject: 'row', lang: 'default', pushSummary: 'row', pushTurnEnd: 'default',
|
|
76
|
+
pushTool: 'env', pushDebounceMs: 'row', serverName: 'row', idleHours: 'default',
|
|
77
|
+
autoShareNewSessions: 'default',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const STATUS = {
|
|
81
|
+
ok: true,
|
|
82
|
+
gateway: GATEWAY,
|
|
83
|
+
gatewayReachable: true,
|
|
84
|
+
gatewayStatus: 200,
|
|
85
|
+
config: { values: VALUES, sources: SOURCES },
|
|
86
|
+
viaGateway: false,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const view = deriveSettingsView(STATUS, { now: NOW, rowUser: { port: 99999, serverName: 'x', lang: 'en' } })
|
|
90
|
+
|
|
91
|
+
assert.equal(view.available, true)
|
|
92
|
+
assert.equal(view.role, 'host')
|
|
93
|
+
assert.equal(view.viaGateway, false)
|
|
94
|
+
assert.equal(view.gatewayReachable, true)
|
|
95
|
+
assert.equal(view.gatewayPort, 3088)
|
|
96
|
+
assert.equal(view.gatewayTarget, '127.0.0.1:3080')
|
|
97
|
+
assert.equal(view.gatewayStatus, 200)
|
|
98
|
+
assert.equal(view.gatewayAbnormal, false, 'a 2xx probe is healthy')
|
|
99
|
+
|
|
100
|
+
// Source annotation: file badge, env lock. A row-layer value another NON-env
|
|
101
|
+
// layer replaced marks the saved value invalid; being shadowed by an env
|
|
102
|
+
// variable is NOT invalid (the lock badge already says so).
|
|
103
|
+
assert.equal(view.fields.port.source, 'file')
|
|
104
|
+
assert.equal(view.fields.port.fromFile, true)
|
|
105
|
+
assert.equal(view.fields.port.locked, false)
|
|
106
|
+
assert.equal(view.fields.port.savedRowInvalid, true, 'row stored a port but the file layer supplied the value')
|
|
107
|
+
assert.equal(view.fields.rateLimit.source, 'env')
|
|
108
|
+
assert.equal(view.fields.rateLimit.locked, true)
|
|
109
|
+
assert.equal(view.fields.rateLimit.savedRowInvalid, false, 'env shadowing is not an invalid saved value')
|
|
110
|
+
assert.equal(view.fields.serverName.savedRowInvalid, false, 'the row layer IS the winner for serverName')
|
|
111
|
+
assert.equal(view.fields.lang.savedRowInvalid, true, 'row stored a lang but the default layer supplied the value')
|
|
112
|
+
assert.equal(view.fields.role.source, 'row')
|
|
113
|
+
assert.equal(view.fields.role.savedRowInvalid, false)
|
|
114
|
+
assert.equal(view.fields.trustedProxies.source, 'default')
|
|
115
|
+
assert.equal(view.fields.trustedProxies.savedRowInvalid, false, 'no row-layer entry, nothing invalid')
|
|
116
|
+
// The effective value rides through verbatim.
|
|
117
|
+
assert.equal(view.fields.port.value, 4000)
|
|
118
|
+
assert.equal(view.fields.pushSummary.value, true)
|
|
119
|
+
|
|
120
|
+
// Devices and the pairing code are read from the NESTED gateway payload.
|
|
121
|
+
assert.deepEqual(view.devices.map((d) => [d.id, d.role, d.kind, d.hasPush]), [
|
|
122
|
+
['d1', 'web', 'phone', true],
|
|
123
|
+
['d2', 'desktop-client', 'auto', false],
|
|
124
|
+
])
|
|
125
|
+
assert.deepEqual(view.pairing, { code: 'AB12CD34', role: 'web', remainingSeconds: 7 })
|
|
126
|
+
assert.equal(
|
|
127
|
+
deriveSettingsView(STATUS, { now: NOW + 6500 }).pairing,
|
|
128
|
+
null,
|
|
129
|
+
'an expired pairing code maps to null',
|
|
130
|
+
)
|
|
131
|
+
assert.equal(
|
|
132
|
+
deriveSettingsView(STATUS, { now: NOW + 1000 }).pairing.remainingSeconds,
|
|
133
|
+
6,
|
|
134
|
+
'the countdown shrinks with the clock',
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// gatewayStatus: a non-null, non-2xx probe means the gateway answered with an
|
|
138
|
+
// error envelope — admin-routes then reports gateway: null, reachable: false.
|
|
139
|
+
const abnormal = deriveSettingsView(
|
|
140
|
+
{ ...STATUS, gateway: null, gatewayReachable: false, gatewayStatus: 503 },
|
|
141
|
+
{ now: NOW },
|
|
142
|
+
)
|
|
143
|
+
assert.equal(abnormal.gatewayAbnormal, true)
|
|
144
|
+
assert.equal(abnormal.gatewayStatus, 503)
|
|
145
|
+
assert.equal(abnormal.gatewayReachable, false)
|
|
146
|
+
assert.deepEqual(abnormal.devices, [], 'a gateway error envelope carries no device list')
|
|
147
|
+
assert.equal(abnormal.pairing, null)
|
|
148
|
+
assert.equal(deriveSettingsView({ ...STATUS, gatewayStatus: 301 }, { now: NOW }).gatewayAbnormal, true)
|
|
149
|
+
assert.equal(deriveSettingsView({ ...STATUS, gatewayStatus: 204 }, { now: NOW }).gatewayAbnormal, false)
|
|
150
|
+
assert.equal(
|
|
151
|
+
deriveSettingsView({ ...STATUS, gateway: null, gatewayReachable: false, gatewayStatus: null }, { now: NOW }).gatewayAbnormal,
|
|
152
|
+
false,
|
|
153
|
+
'no answer at all is "not running", not "abnormal"',
|
|
154
|
+
)
|
|
155
|
+
assert.equal(
|
|
156
|
+
deriveSettingsView({ ...STATUS, gateway: null, gatewayReachable: false, gatewayStatus: null }, { now: NOW }).devices.length,
|
|
157
|
+
0,
|
|
158
|
+
'gateway null renders as no devices',
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
// role: client surfaces as such (the block then hides every group but the role one).
|
|
162
|
+
const clientView = deriveSettingsView(
|
|
163
|
+
{ ...STATUS, config: { ...STATUS.config, values: { ...VALUES, role: 'client' } } },
|
|
164
|
+
{ now: NOW },
|
|
165
|
+
)
|
|
166
|
+
assert.equal(clientView.role, 'client')
|
|
167
|
+
|
|
168
|
+
// viaGateway is the disable flag for every server-local button.
|
|
169
|
+
assert.equal(deriveSettingsView({ ...STATUS, viaGateway: true }, { now: NOW }).viaGateway, true)
|
|
170
|
+
|
|
171
|
+
// localOpsAllowed: the server-local buttons enable only for a viaGateway:false
|
|
172
|
+
// answer — undefined (no answer yet) and viaGateway:true both stay disabled.
|
|
173
|
+
assert.equal(localOpsAllowed(undefined), false, 'no status answer yet counts as remote')
|
|
174
|
+
assert.equal(localOpsAllowed(deriveSettingsView({ ...STATUS, viaGateway: true }, { now: NOW })), false, 'viaGateway:true disables the server-local buttons')
|
|
175
|
+
assert.equal(localOpsAllowed(deriveSettingsView({ ...STATUS, viaGateway: false }, { now: NOW })), true, 'viaGateway:false enables the server-local buttons')
|
|
176
|
+
|
|
177
|
+
// An unshaped body degrades instead of throwing.
|
|
178
|
+
const empty = deriveSettingsView({}, { now: NOW })
|
|
179
|
+
assert.equal(empty.available, false)
|
|
180
|
+
assert.equal(empty.role, 'host')
|
|
181
|
+
assert.equal(empty.gatewayReachable, false)
|
|
182
|
+
assert.equal(empty.gatewayAbnormal, false)
|
|
183
|
+
assert.deepEqual(empty.devices, [])
|
|
184
|
+
assert.equal(empty.pairing, null)
|
|
185
|
+
|
|
186
|
+
// ---- 2. the staged row form ------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/** A fake shared form (the ConfigForm face the Plugins page / settings share). */
|
|
189
|
+
function fakeScope(overrides = {}) {
|
|
190
|
+
const state = {
|
|
191
|
+
snapshot: {
|
|
192
|
+
status: 'ready',
|
|
193
|
+
// The entry's own document: empty here — the row never stored anything
|
|
194
|
+
// (what the fields display instead comes from setBaseline's payload).
|
|
195
|
+
value: {},
|
|
196
|
+
base: {},
|
|
197
|
+
user: {},
|
|
198
|
+
revision: 7,
|
|
199
|
+
writable: true,
|
|
200
|
+
},
|
|
201
|
+
mutateCalls: [],
|
|
202
|
+
listeners: new Set(),
|
|
203
|
+
}
|
|
204
|
+
if (overrides.value !== undefined) state.snapshot.value = overrides.value
|
|
205
|
+
if (overrides.user !== undefined) state.snapshot.user = overrides.user
|
|
206
|
+
if (overrides.ready === false) state.snapshot.status = 'unavailable'
|
|
207
|
+
if (overrides.writable === false) state.snapshot.writable = false
|
|
208
|
+
const scope = {
|
|
209
|
+
getSnapshot: () => state.snapshot,
|
|
210
|
+
subscribe: (listener) => {
|
|
211
|
+
state.listeners.add(listener)
|
|
212
|
+
return () => { state.listeners.delete(listener) }
|
|
213
|
+
},
|
|
214
|
+
mutate: async (ops, expectedRevision) => {
|
|
215
|
+
state.mutateCalls.push({ ops: structuredClone(ops), expectedRevision })
|
|
216
|
+
const accepted = overrides.mutateResult ?? true
|
|
217
|
+
if (accepted) state.snapshot = { ...state.snapshot, revision: state.snapshot.revision + 1 }
|
|
218
|
+
for (const listener of state.listeners) listener()
|
|
219
|
+
return accepted
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
return { scope, state }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test('fields display the EFFECTIVE value while the row layer stores nothing', () => {
|
|
226
|
+
const { scope } = fakeScope()
|
|
227
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
228
|
+
form.setBaseline(VALUES) // config.values from admin/status
|
|
229
|
+
const snap = form.getSnapshot()
|
|
230
|
+
assert.equal(snap.port.text, '4000', 'a file-supplied port shows its effective value')
|
|
231
|
+
assert.equal(snap.pushTool.text, 'true', 'an unstored boolean shows the effective value (on)')
|
|
232
|
+
assert.equal(snap.pushSummary.text, 'true')
|
|
233
|
+
assert.equal(snap.serverName.text, 'studio')
|
|
234
|
+
assert.equal(snap.idleHours.text, '24')
|
|
235
|
+
assert.equal(snap.trustedProxies.text, '', 'an empty effective value stays an empty box')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('a field the row layer stores shows the RESOLVED value unless the saved value is invalid (T15-fix 3)', () => {
|
|
239
|
+
const { scope } = fakeScope({ user: { port: 99999, serverName: ' ' } })
|
|
240
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
241
|
+
form.setBaseline(VALUES)
|
|
242
|
+
// resolveConfig skipped the stored port 99999 (out of range) — the file
|
|
243
|
+
// layer supplies 4000 and admin/status flags savedRowInvalid for port. The
|
|
244
|
+
// page feeds that set in; the box then shows the RAW stored value so the
|
|
245
|
+
// user sees (and can fix) the mistake.
|
|
246
|
+
form.setRowInvalidFields(['port'])
|
|
247
|
+
const snap = form.getSnapshot()
|
|
248
|
+
assert.equal(snap.port.text, '99999', 'the raw (illegal) stored value shows, with the invalid-saved note')
|
|
249
|
+
assert.equal(snap.port.invalid, false, 'a stored value is not a DRAFT; nothing is flagged invalid')
|
|
250
|
+
|
|
251
|
+
// Same stored shape, but the row value is LEGAL: the box shows the
|
|
252
|
+
// normalized resolved value, not the raw one (T15-fix 3).
|
|
253
|
+
const normalized = fakeScope({ user: { trustedProxies: ['10.0.0.1', '10.0.0.2'], pushTool: 1 } })
|
|
254
|
+
const normForm = new ZenRemoteSettingsForm(normalized.scope)
|
|
255
|
+
normForm.setBaseline({ ...VALUES, trustedProxies: '10.0.0.1,10.0.0.2', pushTool: true })
|
|
256
|
+
const normSnap = normForm.getSnapshot()
|
|
257
|
+
assert.equal(normSnap.trustedProxies.text, '10.0.0.1,10.0.0.2', 'a stored array shows the comma string resolveConfig produces')
|
|
258
|
+
assert.equal(normSnap.pushTool.text, 'true', 'a stored 1 shows true, matching what a save writes')
|
|
259
|
+
|
|
260
|
+
// Re-typing the shown (normalized) value is not a phantom change.
|
|
261
|
+
normForm.stage('pushTool', 'true')
|
|
262
|
+
assert.equal(normForm.canSave(), false, 'the draft equals the displayed baseline')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('env-locked fields show the effective value and cannot be staged', async () => {
|
|
266
|
+
const { scope, state } = fakeScope({ user: { rateLimit: 5 } })
|
|
267
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
268
|
+
form.setBaseline({ ...VALUES, rateLimit: 90 }) // env supplies 90
|
|
269
|
+
form.setLockedFields(['rateLimit'])
|
|
270
|
+
const snap = form.getSnapshot()
|
|
271
|
+
assert.equal(snap.rateLimit.locked, true)
|
|
272
|
+
assert.equal(snap.rateLimit.text, '90', 'locked boxes show the effective value, not the shadowed row entry')
|
|
273
|
+
|
|
274
|
+
form.stage('rateLimit', '1')
|
|
275
|
+
assert.equal(snap.rateLimit.text, '90', 'the draft was refused')
|
|
276
|
+
assert.equal(form.canSave(), false)
|
|
277
|
+
assert.equal(await form.save(), false)
|
|
278
|
+
assert.equal(state.mutateCalls.length, 0, 'nothing was staged, nothing was sent')
|
|
279
|
+
|
|
280
|
+
// Even a pre-existing draft for a freshly locked field is skipped by the plan.
|
|
281
|
+
form.stage('port', '4001')
|
|
282
|
+
form.setLockedFields(['port'])
|
|
283
|
+
assert.equal(form.canSave(), false)
|
|
284
|
+
await form.save()
|
|
285
|
+
assert.equal(state.mutateCalls.length, 0)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('the draft comparison baseline is the displayed value', async () => {
|
|
289
|
+
const { scope, state } = fakeScope()
|
|
290
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
291
|
+
form.setBaseline(VALUES) // effective port: 4000 (file), row stores nothing
|
|
292
|
+
form.stage('port', '4000') // re-typing exactly what the box shows
|
|
293
|
+
assert.equal(form.canSave(), false, 'no phantom override for the displayed effective value')
|
|
294
|
+
await form.save()
|
|
295
|
+
assert.equal(state.mutateCalls.length, 0)
|
|
296
|
+
|
|
297
|
+
form.stage('port', '4001')
|
|
298
|
+
assert.equal(form.canSave(), true)
|
|
299
|
+
await form.save()
|
|
300
|
+
assert.deepEqual(state.mutateCalls[0].ops, [{ op: 'set', path: ['port'], value: 4001 }])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('validation: out-of-range drafts block the save instead of being dropped', async () => {
|
|
304
|
+
const { scope, state } = fakeScope()
|
|
305
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
306
|
+
form.setBaseline(VALUES)
|
|
307
|
+
|
|
308
|
+
form.stage('port', '70000')
|
|
309
|
+
assert.equal(form.getSnapshot().port.invalid, true)
|
|
310
|
+
assert.equal(form.getSnapshot().invalid, true)
|
|
311
|
+
assert.equal(form.canSave(), false)
|
|
312
|
+
assert.equal(await form.save(), false)
|
|
313
|
+
assert.equal(state.mutateCalls.length, 0, 'the save refuses while a draft is invalid')
|
|
314
|
+
|
|
315
|
+
form.stage('idleHours', '0')
|
|
316
|
+
assert.equal(form.getSnapshot().idleHours.invalid, true, 'idleHours excludes zero')
|
|
317
|
+
|
|
318
|
+
form.stage('serverName', 'x'.repeat(41))
|
|
319
|
+
assert.equal(form.getSnapshot().serverName.invalid, true, 'serverName caps at 40 characters')
|
|
320
|
+
|
|
321
|
+
form.stage('rateLimit', '0')
|
|
322
|
+
assert.equal(form.getSnapshot().rateLimit.invalid, true, 'rateLimit is a positive integer')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('an empty draft plans an unset for a field the user layer carries', async () => {
|
|
326
|
+
const { scope, state } = fakeScope({ user: { host: '0.0.0.0' } })
|
|
327
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
328
|
+
form.stage('host', '')
|
|
329
|
+
assert.equal(form.canSave(), true)
|
|
330
|
+
await form.save()
|
|
331
|
+
assert.deepEqual(state.mutateCalls[0].ops, [{ op: 'unset', path: ['host'] }])
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('typing-empty plans an unset; a reset of a non-carried field writes nothing', async () => {
|
|
335
|
+
const { scope, state } = fakeScope()
|
|
336
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
337
|
+
form.setBaseline(VALUES) // the box shows the effective mailto:…, so '' is a real change
|
|
338
|
+
// 留空 = 清除:a typed-empty draft plans the unset even when the row layer
|
|
339
|
+
// does not currently carry the field (the composition layer wins on save).
|
|
340
|
+
form.stage('vapidSubject', '')
|
|
341
|
+
assert.equal(form.canSave(), true)
|
|
342
|
+
await form.save()
|
|
343
|
+
assert.deepEqual(state.mutateCalls[0].ops, [{ op: 'unset', path: ['vapidSubject'] }])
|
|
344
|
+
|
|
345
|
+
// The reset control, in contrast, only writes for a field the user layer carries.
|
|
346
|
+
const clean = new ZenRemoteSettingsForm(scope)
|
|
347
|
+
clean.resetField('vapidSubject')
|
|
348
|
+
assert.equal(clean.canSave(), false)
|
|
349
|
+
await clean.save()
|
|
350
|
+
assert.equal(state.mutateCalls.length, 1, 'only the first save reached the wire')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('a legal draft plans a set; a draft equal to the current value plans nothing', async () => {
|
|
354
|
+
const { scope, state } = fakeScope({ value: { host: '127.0.0.1' } })
|
|
355
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
356
|
+
form.stage('port', '4001')
|
|
357
|
+
form.stage('pushSummary', 'true')
|
|
358
|
+
form.stage('lang', 'zh')
|
|
359
|
+
form.stage('role', 'client')
|
|
360
|
+
assert.equal(form.canSave(), true)
|
|
361
|
+
|
|
362
|
+
form.stage('host', '127.0.0.1') // equals the display baseline: no phantom override
|
|
363
|
+
assert.equal(form.getSnapshot().dirty, true, 'other drafts remain staged')
|
|
364
|
+
|
|
365
|
+
await form.save()
|
|
366
|
+
const ops = state.mutateCalls[0].ops.sort((a, b) => a.path[0].localeCompare(b.path[0]))
|
|
367
|
+
assert.deepEqual(ops, [
|
|
368
|
+
{ op: 'set', path: ['lang'], value: 'zh' },
|
|
369
|
+
{ op: 'set', path: ['port'], value: 4001 },
|
|
370
|
+
{ op: 'set', path: ['pushSummary'], value: true },
|
|
371
|
+
{ op: 'set', path: ['role'], value: 'client' },
|
|
372
|
+
])
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('the save fences with the revision the drafts started from', async () => {
|
|
376
|
+
const { scope, state } = fakeScope()
|
|
377
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
378
|
+
form.stage('port', '4001')
|
|
379
|
+
// The mirror moves underneath after staging: the fence must stay at 7.
|
|
380
|
+
state.snapshot = { ...state.snapshot, revision: 9 }
|
|
381
|
+
await form.save()
|
|
382
|
+
assert.equal(state.mutateCalls.length, 1)
|
|
383
|
+
assert.equal(state.mutateCalls[0].expectedRevision, 7)
|
|
384
|
+
assert.equal(form.getSnapshot().dirty, false, 'an accepted save clears the drafts')
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('a refused save keeps its drafts and reports failure', async () => {
|
|
388
|
+
const { scope, state } = fakeScope({ mutateResult: false })
|
|
389
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
390
|
+
form.stage('port', '4001')
|
|
391
|
+
assert.equal(await form.save(), false)
|
|
392
|
+
const snap = form.getSnapshot()
|
|
393
|
+
assert.equal(snap.failed, true)
|
|
394
|
+
assert.equal(snap.dirty, true)
|
|
395
|
+
assert.equal(snap.port.text, '4001')
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('an unavailable or read-only form cannot save', async () => {
|
|
399
|
+
const unavailable = new ZenRemoteSettingsForm(fakeScope({ ready: false }).scope)
|
|
400
|
+
unavailable.stage('port', '4001')
|
|
401
|
+
assert.equal(unavailable.canSave(), false)
|
|
402
|
+
assert.equal(await unavailable.save(), false)
|
|
403
|
+
|
|
404
|
+
const readOnly = new ZenRemoteSettingsForm(fakeScope({ writable: false }).scope)
|
|
405
|
+
readOnly.stage('port', '4001')
|
|
406
|
+
assert.equal(readOnly.canSave(), false)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('rowUser exposes the raw user layer for the savedRowInvalid check', () => {
|
|
410
|
+
const { scope } = fakeScope({ user: { port: 4000, host: '0.0.0.0', rateLimit: 60 } })
|
|
411
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
412
|
+
assert.deepEqual(form.rowUser(), { port: 4000, host: '0.0.0.0', rateLimit: 60 })
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// ---- T15-fix 2: the staged-clear display ------------------------------------
|
|
416
|
+
|
|
417
|
+
test('a staged clear keeps showing the current value and flags `cleared` (T15-fix 2)', async () => {
|
|
418
|
+
// This plugin's base layer is empty, so the old `spec.format(base[field])`
|
|
419
|
+
// preview always rendered an empty string — a fake "new value" that was
|
|
420
|
+
// never true. Now the box keeps the CURRENT value and the field carries
|
|
421
|
+
// `cleared` for the "reverts on save" hint.
|
|
422
|
+
const { scope, state } = fakeScope({ user: { port: 4000 } })
|
|
423
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
424
|
+
form.setBaseline(VALUES) // effective port: 4000 (file layer wins over nothing here)
|
|
425
|
+
form.resetField('port')
|
|
426
|
+
const snap = form.getSnapshot()
|
|
427
|
+
assert.equal(snap.port.cleared, true)
|
|
428
|
+
assert.equal(snap.port.text, '4000', 'the current value keeps showing; no fake preview of the next layer')
|
|
429
|
+
assert.equal(snap.port.overridden, false)
|
|
430
|
+
assert.equal(form.canSave(), true, 'the clear still plans an unset for a stored field')
|
|
431
|
+
|
|
432
|
+
// Selects (role/lang) stay renderable too: the box shows a REAL option.
|
|
433
|
+
const withRole = fakeScope({ user: { role: 'client' } })
|
|
434
|
+
const roleForm = new ZenRemoteSettingsForm(withRole.scope)
|
|
435
|
+
roleForm.resetField('role')
|
|
436
|
+
assert.equal(roleForm.getSnapshot().role.text, 'client')
|
|
437
|
+
assert.equal(roleForm.getSnapshot().role.cleared, true)
|
|
438
|
+
|
|
439
|
+
await form.save()
|
|
440
|
+
assert.deepEqual(state.mutateCalls[0].ops, [{ op: 'unset', path: ['port'] }], 'the clear still writes the unset')
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// ---- T15-fix 4: the latest-wins gate -----------------------------------------
|
|
444
|
+
|
|
445
|
+
test('the latest-wins gate drops tickets that are no longer newest (T15-fix 4)', () => {
|
|
446
|
+
const gate = createLatestGate()
|
|
447
|
+
const first = gate.next()
|
|
448
|
+
assert.equal(gate.isLatest(first), true)
|
|
449
|
+
const second = gate.next()
|
|
450
|
+
assert.equal(gate.isLatest(second), true)
|
|
451
|
+
assert.equal(gate.isLatest(first), false, 'an earlier request answering LATE is dropped')
|
|
452
|
+
assert.notEqual(first, second)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// ---- T16: the client group ----------------------------------------------------
|
|
456
|
+
|
|
457
|
+
test('deriveClientStatusView maps every probe state and tolerates garbage', () => {
|
|
458
|
+
// T59: the view carries the device's own name ('' when the body had none).
|
|
459
|
+
const bare = { state: 'unpaired', serverUrl: '', serverName: '', deviceName: '', nextRetryAt: undefined, lastError: '', intercept: undefined, compat: undefined }
|
|
460
|
+
assert.deepEqual(deriveClientStatusView({ state: 'unpaired' }), bare)
|
|
461
|
+
assert.deepEqual(deriveClientStatusView({ state: 'connected', serverUrl: 'http://192.168.1.10:3088' }), {
|
|
462
|
+
...bare,
|
|
463
|
+
state: 'connected',
|
|
464
|
+
serverUrl: 'http://192.168.1.10:3088',
|
|
465
|
+
})
|
|
466
|
+
assert.deepEqual(deriveClientStatusView({ state: 'revoked', serverUrl: 'https://dsh.example.com' }), {
|
|
467
|
+
...bare,
|
|
468
|
+
state: 'revoked',
|
|
469
|
+
serverUrl: 'https://dsh.example.com',
|
|
470
|
+
})
|
|
471
|
+
assert.deepEqual(deriveClientStatusView({ state: 'unreachable', serverUrl: 'http://x.local:1' }), {
|
|
472
|
+
...bare,
|
|
473
|
+
state: 'unreachable',
|
|
474
|
+
serverUrl: 'http://x.local:1',
|
|
475
|
+
})
|
|
476
|
+
assert.equal(deriveClientStatusView({ state: 'unexpected', serverUrl: 'http://x.local:1' }).state, 'unexpected')
|
|
477
|
+
assert.equal(deriveClientStatusView({ state: 'incompatible' }).state, 'incompatible', 'T43: the protocol mismatch renders its own word')
|
|
478
|
+
// T16-fix 1: the stored address failed re-validation; nothing was probed.
|
|
479
|
+
assert.deepEqual(deriveClientStatusView({ state: 'invalid-url' }), { ...bare, state: 'invalid-url' })
|
|
480
|
+
// Degraded shapes: unknown state words and non-objects fall back to unpaired.
|
|
481
|
+
assert.equal(deriveClientStatusView({ state: 'gibberish', serverUrl: 'http://x' }).state, 'unpaired')
|
|
482
|
+
assert.deepEqual(deriveClientStatusView({}), bare)
|
|
483
|
+
assert.deepEqual(deriveClientStatusView(undefined), bare)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
test('T43: the status view carries the reconnect readout only for well-shaped values', () => {
|
|
487
|
+
const view = deriveClientStatusView({
|
|
488
|
+
state: 'unreachable',
|
|
489
|
+
serverUrl: 'http://x.local:1',
|
|
490
|
+
serverName: '书房服务器',
|
|
491
|
+
nextRetryAt: 1_700_000_005_000,
|
|
492
|
+
lastError: 'offline',
|
|
493
|
+
})
|
|
494
|
+
assert.equal(view.serverName, '书房服务器')
|
|
495
|
+
assert.equal(view.nextRetryAt, 1_700_000_005_000)
|
|
496
|
+
assert.equal(view.lastError, 'offline')
|
|
497
|
+
|
|
498
|
+
// Garbage degrades: non-numeric nextRetryAt is absent, a non-string
|
|
499
|
+
// lastError is '', a non-string serverName is ''.
|
|
500
|
+
const junk = deriveClientStatusView({ state: 'unreachable', nextRetryAt: 'soon', lastError: 42, serverName: 7 })
|
|
501
|
+
assert.equal(junk.nextRetryAt, undefined)
|
|
502
|
+
assert.equal(junk.lastError, '')
|
|
503
|
+
assert.equal(junk.serverName, '')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
test('T43: intercept/compat render only when the body carried the field', () => {
|
|
507
|
+
// Absent on an older server — nothing to render.
|
|
508
|
+
assert.equal(deriveClientStatusView({ state: 'unreachable' }).intercept, undefined)
|
|
509
|
+
assert.equal(deriveClientStatusView({ state: 'unreachable' }).compat, undefined)
|
|
510
|
+
|
|
511
|
+
const view = deriveClientStatusView({
|
|
512
|
+
state: 'unreachable',
|
|
513
|
+
intercept: {
|
|
514
|
+
installed: true,
|
|
515
|
+
reasons: ['AssistantMarkdown root missing'],
|
|
516
|
+
recentFailures: [
|
|
517
|
+
{ time: 1_700_000_000_000, method: 'session/page', code: 'gateway/invocation-unavailable' },
|
|
518
|
+
// The interceptor's actual wire dialect (T42-fix): ISO time under
|
|
519
|
+
// `endpoint` — both must render, not 1970 and "—".
|
|
520
|
+
{ time: '2026-09-29T01:02:03.000Z', endpoint: 'session/follow', code: 'offline' },
|
|
521
|
+
{ time: 'x', code: 42 },
|
|
522
|
+
],
|
|
523
|
+
},
|
|
524
|
+
compat: {
|
|
525
|
+
identical: ['session'],
|
|
526
|
+
different: ['workspace', 'goal'],
|
|
527
|
+
unavailable: ['events'],
|
|
528
|
+
incompatibleCalls: [{ time: 1_700_000_000_000, endpoint: 'session/page', code: 'gateway/arguments-invalid' }],
|
|
529
|
+
},
|
|
530
|
+
})
|
|
531
|
+
assert.deepEqual(view.intercept, {
|
|
532
|
+
installed: true,
|
|
533
|
+
reasons: ['AssistantMarkdown root missing'],
|
|
534
|
+
recentFailures: [
|
|
535
|
+
{ time: 1_700_000_000_000, method: 'session/page', code: 'gateway/invocation-unavailable' },
|
|
536
|
+
{ time: Date.parse('2026-09-29T01:02:03.000Z'), method: 'session/follow', code: 'offline' },
|
|
537
|
+
{ time: 0, method: '', code: '' },
|
|
538
|
+
],
|
|
539
|
+
})
|
|
540
|
+
// T42: the view keeps its render names (mismatchedGroups / recentCalls,
|
|
541
|
+
// endpoint rows mapped onto method) while the WIRE names are the status
|
|
542
|
+
// route's different / incompatibleCalls.
|
|
543
|
+
assert.deepEqual(view.compat, {
|
|
544
|
+
mismatchedGroups: ['workspace', 'goal'],
|
|
545
|
+
recentCalls: [{ time: 1_700_000_000_000, method: 'session/page', code: 'gateway/arguments-invalid' }],
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// Present but empty/garbage still renders the group (installed defaults
|
|
549
|
+
// false, lists empty) — the field's presence is the gate, not its shape.
|
|
550
|
+
const empty = deriveClientStatusView({ state: 'unreachable', intercept: {}, compat: null })
|
|
551
|
+
assert.deepEqual(empty.intercept, { installed: false, reasons: [], recentFailures: [] })
|
|
552
|
+
assert.equal(empty.compat, undefined, 'null counts as absent')
|
|
553
|
+
|
|
554
|
+
// T42-fix: the MOST RECENT 10 rows survive a longer ring (the rings are
|
|
555
|
+
// oldest-first, so the tail is the fresh end), not the oldest ten.
|
|
556
|
+
const flooded = deriveClientStatusView({
|
|
557
|
+
state: 'unreachable',
|
|
558
|
+
intercept: { recentFailures: Array.from({ length: 14 }, (_, i) => ({ time: 1_000 + i, method: `m${i}`, code: 'x' })) },
|
|
559
|
+
})
|
|
560
|
+
assert.equal(flooded.intercept.recentFailures.length, 10)
|
|
561
|
+
assert.deepEqual(flooded.intercept.recentFailures[0], { time: 1_004, method: 'm4', code: 'x' })
|
|
562
|
+
assert.deepEqual(flooded.intercept.recentFailures[9], { time: 1_013, method: 'm13', code: 'x' })
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
// ---- T43: the connection-line copy selector ---------------------------------
|
|
566
|
+
|
|
567
|
+
const viewOf = (extra = {}) => deriveClientStatusView({ state: 'unreachable', serverUrl: 'http://x.local:1', ...extra })
|
|
568
|
+
|
|
569
|
+
test('clientStatusLineOf picks the diagnostics wording per state', () => {
|
|
570
|
+
// Connected prefers the handshake's server name, falls back to the URL.
|
|
571
|
+
assert.deepEqual(
|
|
572
|
+
clientStatusLineOf(viewOf({ state: 'connected', serverName: '书房服务器' }), NOW),
|
|
573
|
+
{ kind: 'connectedName', serverName: '书房服务器' },
|
|
574
|
+
)
|
|
575
|
+
assert.deepEqual(
|
|
576
|
+
clientStatusLineOf(viewOf({ state: 'connected' }), NOW),
|
|
577
|
+
{ kind: 'connected', serverUrl: 'http://x.local:1' },
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
// Offline with a pending retry counts down; overdue collapses to "soon".
|
|
581
|
+
assert.deepEqual(
|
|
582
|
+
clientStatusLineOf(viewOf({ nextRetryAt: NOW + 9_400 }), NOW),
|
|
583
|
+
{ kind: 'offlineRetry', seconds: 10 },
|
|
584
|
+
'the countdown rounds up to whole seconds',
|
|
585
|
+
)
|
|
586
|
+
assert.deepEqual(
|
|
587
|
+
clientStatusLineOf(viewOf({ nextRetryAt: NOW + 1_000 }), NOW + 1_500),
|
|
588
|
+
{ kind: 'offlineRetrySoon' },
|
|
589
|
+
'an overdue wait reads as imminent',
|
|
590
|
+
)
|
|
591
|
+
assert.deepEqual(
|
|
592
|
+
clientStatusLineOf(viewOf({ nextRetryAt: NOW + 500 }), NOW),
|
|
593
|
+
{ kind: 'offlineRetry', seconds: 1 },
|
|
594
|
+
)
|
|
595
|
+
// The probe fallback (no relay client) has no nextRetryAt.
|
|
596
|
+
assert.deepEqual(clientStatusLineOf(viewOf({}), NOW), { kind: 'unreachable' })
|
|
597
|
+
|
|
598
|
+
// T43-fix: the countdown follows the nextRetryAt, whatever the probe
|
|
599
|
+
// word underneath — an `unexpected` verdict backed by a pending retry is
|
|
600
|
+
// still OFFLINE with a ladder running.
|
|
601
|
+
assert.deepEqual(
|
|
602
|
+
clientStatusLineOf(viewOf({ state: 'unexpected', nextRetryAt: NOW + 4_000 }), NOW),
|
|
603
|
+
{ kind: 'offlineRetry', seconds: 4 },
|
|
604
|
+
)
|
|
605
|
+
// Without a nextRetryAt the two verdicts keep their own plain copy.
|
|
606
|
+
assert.deepEqual(clientStatusLineOf(viewOf({ state: 'unexpected' }), NOW), { kind: 'unexpected' })
|
|
607
|
+
|
|
608
|
+
assert.deepEqual(clientStatusLineOf(viewOf({ state: 'revoked' }), NOW), { kind: 'revoked' })
|
|
609
|
+
assert.deepEqual(clientStatusLineOf(viewOf({ state: 'incompatible' }), NOW), { kind: 'incompatible' })
|
|
610
|
+
assert.deepEqual(clientStatusLineOf(viewOf({ state: 'unexpected' }), NOW), { kind: 'unexpected' })
|
|
611
|
+
assert.deepEqual(clientStatusLineOf(viewOf({ state: 'invalid-url' }), NOW), { kind: 'invalidUrl' })
|
|
612
|
+
assert.deepEqual(clientStatusLineOf(viewOf({ state: 'unpaired' }), NOW), { kind: 'unpaired' })
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
test('CLIENT_RECONNECT_ROUTE is the one reconnect endpoint', () => {
|
|
616
|
+
assert.equal(CLIENT_RECONNECT_ROUTE, '/_dsh/zen-remote/client/reconnect')
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
test('normalizePairingCode uppercases and strips spaces and hyphens', () => {
|
|
620
|
+
assert.equal(normalizePairingCode('ab cd-12'), 'ABCD12')
|
|
621
|
+
assert.equal(normalizePairingCode(' abcd12ef '), 'ABCD12EF')
|
|
622
|
+
assert.equal(normalizePairingCode('abcd-12-ef'), 'ABCD12EF')
|
|
623
|
+
assert.equal(normalizePairingCode(''), '')
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
test('writeClientPairing lands one mutate — address, token AND the registered name (T60) — and clears the draft fence', async () => {
|
|
627
|
+
const { scope, state } = fakeScope()
|
|
628
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
629
|
+
assert.equal(await form.writeClientPairing('http://192.168.1.10:3088', 'tok-xyz', '书房的台式机'), true)
|
|
630
|
+
assert.equal(state.mutateCalls.length, 1)
|
|
631
|
+
assert.deepEqual(state.mutateCalls[0].ops, [
|
|
632
|
+
{ op: 'set', path: ['serverUrl'], value: 'http://192.168.1.10:3088' },
|
|
633
|
+
{ op: 'set', path: ['deviceToken'], value: 'tok-xyz' },
|
|
634
|
+
{ op: 'set', path: ['serverName'], value: '书房的台式机' },
|
|
635
|
+
], 'the registered name lands in the row with the credentials (T60)')
|
|
636
|
+
assert.equal(state.mutateCalls[0].expectedRevision, 7, 'fenced with the current revision')
|
|
637
|
+
|
|
638
|
+
// Staged drafts survive the direct write, and their fence refreshes: a
|
|
639
|
+
// draft staged BEFORE the pairing write must save against the NEW revision,
|
|
640
|
+
// not the one it was staged under.
|
|
641
|
+
const staged = fakeScope()
|
|
642
|
+
const stForm = new ZenRemoteSettingsForm(staged.scope)
|
|
643
|
+
stForm.stage('host', '0.0.0.0')
|
|
644
|
+
assert.equal(await stForm.writeClientPairing('http://x.local', 'tok', '台式机'), true)
|
|
645
|
+
await stForm.save()
|
|
646
|
+
assert.equal(staged.state.mutateCalls[1].expectedRevision, 8, 'the fence moved with the row, not with the stale stage time')
|
|
647
|
+
assert.deepEqual(staged.state.mutateCalls[1].ops, [{ op: 'set', path: ['host'], value: '0.0.0.0' }])
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
test('T60: the name draft did its job — discardField retires it once the pairing write landed', async () => {
|
|
651
|
+
const { scope } = fakeScope({ value: { role: 'client' } })
|
|
652
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
653
|
+
// The user typed a (legal) name and paired WITHOUT saving: the draft
|
|
654
|
+
// stages, the claim uses it, and the write lands it in the row.
|
|
655
|
+
form.stage('serverName', '书房的台式机')
|
|
656
|
+
assert.equal(form.hasDraft('serverName'), true)
|
|
657
|
+
assert.equal(await form.writeClientPairing('http://x.local', 'tok', '书房的台式机'), true)
|
|
658
|
+
form.discardField('serverName')
|
|
659
|
+
assert.equal(form.hasDraft('serverName'), false, 'the draft now equals the displayed value — no phantom edit')
|
|
660
|
+
// The row document reads the landed name for the prefill.
|
|
661
|
+
assert.equal(form.rowValue('serverName'), undefined, 'the fake scope keeps the ops; the real row would answer the name')
|
|
662
|
+
|
|
663
|
+
// A field with no draft is a no-op; another field's draft survives.
|
|
664
|
+
form.stage('role', 'client')
|
|
665
|
+
form.discardField('serverName')
|
|
666
|
+
form.discardField('host')
|
|
667
|
+
assert.equal(form.hasDraft('role'), true, 'unrelated drafts untouched')
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
test('clearDeviceToken unsets only the token and keeps the address', async () => {
|
|
671
|
+
const { scope, state } = fakeScope()
|
|
672
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
673
|
+
assert.equal(await form.clearDeviceToken(), true)
|
|
674
|
+
assert.deepEqual(state.mutateCalls[0].ops, [{ op: 'unset', path: ['deviceToken'] }])
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
test('the direct writes refuse when the form is unavailable, read-only or already saving', async () => {
|
|
678
|
+
const unavailable = new ZenRemoteSettingsForm(fakeScope({ ready: false }).scope)
|
|
679
|
+
assert.equal(await unavailable.writeClientPairing('http://x.local', 'tok', '名'), false)
|
|
680
|
+
assert.equal(await unavailable.clearDeviceToken(), false)
|
|
681
|
+
|
|
682
|
+
const readOnly = new ZenRemoteSettingsForm(fakeScope({ writable: false }).scope)
|
|
683
|
+
assert.equal(await readOnly.writeClientPairing('http://x.local', 'tok', '名'), false)
|
|
684
|
+
|
|
685
|
+
// A refused mutate reports failure through the return value; the frame's
|
|
686
|
+
// staged-save `failed` flag stays untouched (pairing has its own copy).
|
|
687
|
+
const refused = fakeScope({ mutateResult: false })
|
|
688
|
+
const refusedForm = new ZenRemoteSettingsForm(refused.scope)
|
|
689
|
+
assert.equal(await refusedForm.clearDeviceToken(), false)
|
|
690
|
+
assert.equal(refusedForm.getSnapshot().failed, false)
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
test('the device token rides the snapshot as presence only, and rowValue reads the saved document', () => {
|
|
694
|
+
let configured = false
|
|
695
|
+
const { scope } = fakeScope({ value: { role: 'client', serverUrl: 'http://192.168.1.10:3088' } })
|
|
696
|
+
const form = new ZenRemoteSettingsForm(scope, () => configured)
|
|
697
|
+
assert.equal(form.getSnapshot().deviceToken.configured, false)
|
|
698
|
+
assert.equal(form.rowValue('role'), 'client')
|
|
699
|
+
assert.equal(form.rowValue('serverUrl'), 'http://192.168.1.10:3088')
|
|
700
|
+
assert.equal(form.rowValue('deviceToken'), undefined, 'secrets never ride the form document')
|
|
701
|
+
|
|
702
|
+
configured = true
|
|
703
|
+
form.refresh()
|
|
704
|
+
assert.equal(form.getSnapshot().deviceToken.configured, true, 'the describe follow republishes')
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
// ---- T16-fix 3 + T17: the page's role and status-source decisions -----------
|
|
708
|
+
|
|
709
|
+
test('savedRowRole reads the SAVED role from the snapshot, never a display draft', () => {
|
|
710
|
+
// The schema-resolved section carries the role…
|
|
711
|
+
assert.equal(savedRowRole({ value: { role: 'client' }, user: {} }), 'client')
|
|
712
|
+
// …but a row value kept only in the raw user layer counts too…
|
|
713
|
+
assert.equal(savedRowRole({ value: {}, user: { role: 'client' } }), 'client')
|
|
714
|
+
// …an explicit host is known…
|
|
715
|
+
assert.equal(savedRowRole({ value: { role: 'host' }, user: {} }), 'host')
|
|
716
|
+
assert.equal(savedRowRole({ value: {}, user: { role: 'host' } }), 'host')
|
|
717
|
+
// …and T17: a row without any role — or with an unrecognized one — answers
|
|
718
|
+
// undefined, meaning "ask the client-config probe", NOT a host assumption.
|
|
719
|
+
assert.equal(savedRowRole({ value: {}, user: {} }), undefined)
|
|
720
|
+
assert.equal(savedRowRole({ value: { role: 'Client' }, user: {} }), undefined, 'exact match only')
|
|
721
|
+
assert.equal(savedRowRole({}), undefined)
|
|
722
|
+
assert.equal(savedRowRole({ value: undefined, user: undefined }), undefined)
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
test('settingsPollOf: loading polls nothing; a known row role decides; otherwise wait for the probe', () => {
|
|
726
|
+
assert.equal(settingsPollOf('loading', { value: { role: 'client' } }), 'none', 'the role is not knowable while the mirror loads')
|
|
727
|
+
assert.equal(settingsPollOf('ready', { value: { role: 'client' } }), 'client')
|
|
728
|
+
assert.equal(settingsPollOf('ready', { user: { role: 'client' } }), 'client')
|
|
729
|
+
assert.equal(settingsPollOf('ready', { value: { role: 'host' } }), 'admin')
|
|
730
|
+
// T17: no row role (the value lives only in lan-gate.config.json) — the
|
|
731
|
+
// page polls NOTHING until the client-config probe answers, so a file-layer
|
|
732
|
+
// client never sees a wasted admin/status 404.
|
|
733
|
+
assert.equal(settingsPollOf('ready', { value: {} }), 'none')
|
|
734
|
+
assert.equal(settingsPollOf('unavailable', { value: {} }), 'none')
|
|
735
|
+
assert.equal(settingsPollOf('ready', { value: {} }, 'client'), 'client', 'the probe answers client')
|
|
736
|
+
assert.equal(settingsPollOf('ready', { value: {} }, 'host'), 'admin', 'the probe answers host')
|
|
737
|
+
// A stored row role always beats the probe.
|
|
738
|
+
assert.equal(settingsPollOf('ready', { value: { role: 'host' } }, 'client'), 'admin')
|
|
739
|
+
assert.equal(settingsPollOf('ready', { value: { role: 'client' } }, 'host'), 'client')
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
test('the controller falls back to the probed role; a stored row role wins over it', () => {
|
|
743
|
+
const noRole = new ZenRemoteSettingsForm(fakeScope({ value: {} }).scope)
|
|
744
|
+
assert.equal(noRole.savedRoleIsClient(), false, 'no row role, no probe yet: host default')
|
|
745
|
+
assert.equal(noRole.statusPoll(), 'none', 'the poll waits for the probe')
|
|
746
|
+
noRole.setProbedRole('client')
|
|
747
|
+
assert.equal(noRole.savedRoleIsClient(), true, 'the probe flips the page to client mode')
|
|
748
|
+
assert.equal(noRole.statusPoll(), 'client')
|
|
749
|
+
noRole.setProbedRole('host')
|
|
750
|
+
assert.equal(noRole.savedRoleIsClient(), false)
|
|
751
|
+
assert.equal(noRole.statusPoll(), 'admin')
|
|
752
|
+
noRole.setProbedRole(undefined)
|
|
753
|
+
assert.equal(noRole.statusPoll(), 'none', 'clearing the probe re-arms the wait')
|
|
754
|
+
|
|
755
|
+
const hostRow = new ZenRemoteSettingsForm(fakeScope({ value: { role: 'host' } }).scope)
|
|
756
|
+
hostRow.setProbedRole('client')
|
|
757
|
+
assert.equal(hostRow.savedRoleIsClient(), false, 'a stored row role beats a stale probe')
|
|
758
|
+
assert.equal(hostRow.statusPoll(), 'admin')
|
|
759
|
+
const clientRow = new ZenRemoteSettingsForm(fakeScope({ user: { role: 'client' } }).scope)
|
|
760
|
+
clientRow.setProbedRole('host')
|
|
761
|
+
assert.equal(clientRow.savedRoleIsClient(), true)
|
|
762
|
+
assert.equal(clientRow.statusPoll(), 'client')
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
test('the poll source follows a role switch at the NEXT snapshot, admin never on client', () => {
|
|
766
|
+
// host → client: the new snapshot's decision references only its own role.
|
|
767
|
+
const before = settingsPollOf('ready', { value: { role: 'host' } })
|
|
768
|
+
assert.equal(before, 'admin')
|
|
769
|
+
const after = settingsPollOf('ready', { value: { role: 'client' } })
|
|
770
|
+
assert.equal(after, 'client', 'after switching to client, only client/status is polled')
|
|
771
|
+
// Reverse direction.
|
|
772
|
+
assert.equal(settingsPollOf('ready', { value: { role: 'host' } }), 'admin')
|
|
773
|
+
// The controller reads the same decisions off its live scope.
|
|
774
|
+
const clientScope = fakeScope({ value: { role: 'client' } }).scope
|
|
775
|
+
const clientForm = new ZenRemoteSettingsForm(clientScope)
|
|
776
|
+
assert.equal(clientForm.savedRoleIsClient(), true)
|
|
777
|
+
assert.equal(clientForm.statusPoll(), 'client')
|
|
778
|
+
assert.equal(clientForm.scopeStatus(), 'ready')
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
// ---- T17: the plugin-reload note ---------------------------------------------
|
|
782
|
+
|
|
783
|
+
test('restartPending tracks staged drafts over the restart-required fields only', async () => {
|
|
784
|
+
// The row layer stores port, so the staged clear below plans an unset.
|
|
785
|
+
const { scope, state } = fakeScope({ value: { port: 4000 }, user: { port: 4000 } })
|
|
786
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
787
|
+
form.setBaseline(VALUES)
|
|
788
|
+
assert.equal(form.getSnapshot().restartPending, false, 'nothing staged yet')
|
|
789
|
+
|
|
790
|
+
form.stage('serverName', 'renamed')
|
|
791
|
+
assert.equal(form.getSnapshot().restartPending, false, 'serverName is live-read; no reload')
|
|
792
|
+
|
|
793
|
+
form.stage('port', '4001')
|
|
794
|
+
const pending = form.getSnapshot()
|
|
795
|
+
assert.equal(pending.restartPending, true, 'a staged port reloads the row')
|
|
796
|
+
assert.equal(pending.dirty, true)
|
|
797
|
+
|
|
798
|
+
form.discard()
|
|
799
|
+
assert.equal(form.getSnapshot().restartPending, false, 'discard clears the note')
|
|
800
|
+
|
|
801
|
+
form.stage('role', 'client')
|
|
802
|
+
assert.equal(form.getSnapshot().restartPending, true, 'the role is a restart field too')
|
|
803
|
+
form.resetField('port')
|
|
804
|
+
assert.equal(form.getSnapshot().restartPending, true, 'a staged clear of a restart field counts as well')
|
|
805
|
+
|
|
806
|
+
assert.equal(await form.save(), true)
|
|
807
|
+
const savedOps = state.mutateCalls[0].ops
|
|
808
|
+
assert.deepEqual(savedOps, [
|
|
809
|
+
{ op: 'set', path: ['role'], value: 'client' },
|
|
810
|
+
{ op: 'unset', path: ['port'] },
|
|
811
|
+
], 'both restart-field drafts landed, in field order')
|
|
812
|
+
assert.equal(form.getSnapshot().restartPending, false, 'a landed save clears the staged drafts — the reload takes over from here')
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
test('restartPending only promises a reload when a save would actually write a change (T17b)', async () => {
|
|
816
|
+
// A draft equal to the displayed effective value plans no op → no note.
|
|
817
|
+
const { scope } = fakeScope({ value: { port: 4000 }, user: { port: 4000 } })
|
|
818
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
819
|
+
form.setBaseline(VALUES) // effective port: 4000
|
|
820
|
+
form.stage('port', '4000')
|
|
821
|
+
assert.equal(form.getSnapshot().restartPending, false, 'a draft equal to the effective value is no change at all')
|
|
822
|
+
assert.equal(form.getSnapshot().dirty, false)
|
|
823
|
+
|
|
824
|
+
// An invalid draft blocks the save instead of saving anything → no note.
|
|
825
|
+
form.stage('port', '70000')
|
|
826
|
+
const invalidSnap = form.getSnapshot()
|
|
827
|
+
assert.equal(invalidSnap.restartPending, false, 'an out-of-range draft never saves, so no reload is promised')
|
|
828
|
+
assert.equal(invalidSnap.invalid, true)
|
|
829
|
+
|
|
830
|
+
// An env-locked restart field stages nothing — and a draft staged before
|
|
831
|
+
// the lock landed does not count either (a written value would be
|
|
832
|
+
// shadowed by the variable, and plan() skips locked fields).
|
|
833
|
+
const env = fakeScope({ user: { port: 4000 } })
|
|
834
|
+
const envForm = new ZenRemoteSettingsForm(env.scope)
|
|
835
|
+
envForm.setBaseline(VALUES)
|
|
836
|
+
envForm.stage('port', '4001')
|
|
837
|
+
assert.equal(envForm.getSnapshot().restartPending, true, 'still a plain staged change here')
|
|
838
|
+
envForm.setLockedFields(['port'])
|
|
839
|
+
assert.equal(envForm.getSnapshot().restartPending, false, 'an env-sourced field never promises a reload')
|
|
840
|
+
|
|
841
|
+
// A staged clear of a restart field the row layer does NOT carry plans
|
|
842
|
+
// no op → no note.
|
|
843
|
+
const clear = fakeScope()
|
|
844
|
+
const clearForm = new ZenRemoteSettingsForm(clear.scope)
|
|
845
|
+
clearForm.setBaseline(VALUES)
|
|
846
|
+
clearForm.resetField('port')
|
|
847
|
+
assert.equal(clearForm.getSnapshot().restartPending, false, 'nothing to revert: the row stores no port')
|
|
848
|
+
|
|
849
|
+
// A real value change still promises the reload.
|
|
850
|
+
clearForm.stage('port', '4001')
|
|
851
|
+
assert.equal(clearForm.getSnapshot().restartPending, true, 'a real change keeps the note')
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
// ---- T57: the device name on the role card, both roles -----------------------
|
|
855
|
+
|
|
856
|
+
test('T57: serverName stages and saves on a CLIENT page — the role card is its only home', async () => {
|
|
857
|
+
// The device name (the row's serverName field) renders in the ROLE card on
|
|
858
|
+
// both roles now; on a client page (no admin/status, no baseline since T55)
|
|
859
|
+
// an unstored name displays EMPTY and staging still plans the write.
|
|
860
|
+
const { scope, state } = fakeScope({ value: { role: 'client' } })
|
|
861
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
862
|
+
assert.equal(form.statusPoll(), 'client')
|
|
863
|
+
assert.equal(form.getSnapshot().serverName.text, '', 'an unstored name displays empty on a client page')
|
|
864
|
+
|
|
865
|
+
form.stage('serverName', '书房的台式机')
|
|
866
|
+
const staged = form.getSnapshot()
|
|
867
|
+
assert.equal(staged.serverName.overridden, true)
|
|
868
|
+
assert.equal(staged.serverName.invalid, false)
|
|
869
|
+
assert.equal(form.canSave(), true)
|
|
870
|
+
assert.equal(await form.save(), true)
|
|
871
|
+
assert.deepEqual(state.mutateCalls[0].ops, [{ op: 'set', path: ['serverName'], value: '书房的台式机' }])
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
test('T57: the pairing claim name follows the displayed value, else the default copy', () => {
|
|
875
|
+
// A legal displayed value — staged draft or stored value — IS the name.
|
|
876
|
+
assert.equal(claimDeviceNameOf('书房的台式机', '桌面应用端'), '书房的台式机')
|
|
877
|
+
// An EMPTY field (the client page's unstored display) falls back.
|
|
878
|
+
assert.equal(claimDeviceNameOf('', '桌面应用端'), '桌面应用端')
|
|
879
|
+
// nameField's other non-values are not names either: blank-only, over cap.
|
|
880
|
+
assert.equal(claimDeviceNameOf(' ', '桌面应用端'), '桌面应用端')
|
|
881
|
+
assert.equal(claimDeviceNameOf('x'.repeat(41), '桌面应用端'), '桌面应用端')
|
|
882
|
+
assert.equal(claimDeviceNameOf('x'.repeat(40), '桌面应用端'), 'x'.repeat(40), 'the cap itself is legal')
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
test('T60: the claim name is TRIMMED first — the registered name carries no padding', () => {
|
|
886
|
+
assert.equal(claimDeviceNameOf(' 书房 ', '桌面应用端'), '书房', 'trim, then judge, then return the trimmed text')
|
|
887
|
+
assert.equal(claimDeviceNameOf('\t书房\n', '桌面应用端'), '书房', 'all whitespace kinds count')
|
|
888
|
+
// 40 characters PLUS padding: judged on the trimmed text, so still legal.
|
|
889
|
+
assert.equal(claimDeviceNameOf(' ' + 'x'.repeat(40) + ' ', '桌面应用端'), 'x'.repeat(40))
|
|
890
|
+
// 40 characters of which padding is a PART is over the cap once trimmed
|
|
891
|
+
// the other way — the judge is always the trimmed text.
|
|
892
|
+
assert.equal(claimDeviceNameOf('x'.repeat(39) + ' ', '桌面应用端'), 'x'.repeat(39))
|
|
893
|
+
// Whitespace-only is not a name even though the raw text was non-empty.
|
|
894
|
+
assert.equal(claimDeviceNameOf(' \t ', '桌面应用端'), '桌面应用端')
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
// ---- T59: the device-name two-way sync ----------------------------------------
|
|
898
|
+
|
|
899
|
+
test('T59: the client/status deviceName parses; absent stays empty', () => {
|
|
900
|
+
const view = deriveClientStatusView({ state: 'online', serverName: '书房', serverUrl: 'http://x.local', deviceName: '书房的台式机' })
|
|
901
|
+
assert.equal(view.state, 'connected')
|
|
902
|
+
assert.equal(view.deviceName, '书房的台式机')
|
|
903
|
+
// An older server answers without the field: nothing to follow.
|
|
904
|
+
assert.equal(deriveClientStatusView({ state: 'online', serverUrl: 'http://x.local' }).deviceName, '')
|
|
905
|
+
// A non-string degrades to empty like every other wire field.
|
|
906
|
+
assert.equal(deriveClientStatusView({ state: 'online', deviceName: 42 }).deviceName, '')
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
test('T59: the follow decision — server name wins only when carried, differing, and unedited', () => {
|
|
910
|
+
assert.equal(shouldFollowServerDeviceName('服务端名', '本地旧名', false), true, 'differing and unedited: follow')
|
|
911
|
+
assert.equal(shouldFollowServerDeviceName('', '本地旧名', false), false, 'an absent record is nothing to follow')
|
|
912
|
+
assert.equal(shouldFollowServerDeviceName('服务端名', '服务端名', false), false, 'equal is the echo of our own write — the anti-bounce half')
|
|
913
|
+
assert.equal(shouldFollowServerDeviceName('服务端名', '本地旧名', true), false, 'a staged draft means the user is mid-edit; their text wins')
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
test('T59: hasDraft tracks the staged map; writeDeviceName lands one serverName set', async () => {
|
|
917
|
+
const { scope, state } = fakeScope({ value: { role: 'client' } })
|
|
918
|
+
const form = new ZenRemoteSettingsForm(scope)
|
|
919
|
+
assert.equal(form.hasDraft('serverName'), false, 'nothing staged yet')
|
|
920
|
+
|
|
921
|
+
form.stage('serverName', '用户正在打字')
|
|
922
|
+
assert.equal(form.hasDraft('serverName'), true, 'a draft blocks the follow')
|
|
923
|
+
assert.equal(await form.writeDeviceName('服务端名'), true, 'the write itself is still possible — the page just must not call it now')
|
|
924
|
+
|
|
925
|
+
form.discard()
|
|
926
|
+
assert.equal(form.hasDraft('serverName'), false)
|
|
927
|
+
|
|
928
|
+
// The follow write: ONE direct mutate, the row's serverName field, no
|
|
929
|
+
// token or address touched.
|
|
930
|
+
assert.equal(await form.writeDeviceName('服务端的名'), true)
|
|
931
|
+
assert.deepEqual(state.mutateCalls.at(-1).ops, [{ op: 'set', path: ['serverName'], value: '服务端的名' }])
|
|
932
|
+
assert.equal(state.mutateCalls.at(-1).expectedRevision, 8, 'fenced like every direct write')
|
|
933
|
+
})
|