dsh-zen-remote 2.2.8 → 2.3.1

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 (162) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/docs/interface.md +1 -1
  4. package/docs/remote-access.md +20 -0
  5. package/lan-gate.mjs +11 -1
  6. package/lib/adapters/dsh-better-sidebar/index.js +6 -2
  7. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  8. package/lib/adapters/dsh-better-sidebar/methods.js +3 -0
  9. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
  10. package/lib/admin-routes.js +33 -2
  11. package/lib/admin-routes.js.map +1 -1
  12. package/lib/client-routes.js +52 -0
  13. package/lib/client-routes.js.map +1 -1
  14. package/lib/client.js +3390 -10
  15. package/lib/config.js +70 -0
  16. package/lib/config.js.map +1 -1
  17. package/lib/index.js +232 -2
  18. package/lib/index.js.map +1 -1
  19. package/lib/intercept.js +43 -3
  20. package/lib/intercept.js.map +1 -1
  21. package/lib/lan-gate-server.cjs +29 -3
  22. package/lib/merge-streams.js +42 -12
  23. package/lib/merge-streams.js.map +1 -1
  24. package/lib/relay-client.js +71 -0
  25. package/lib/relay-client.js.map +1 -1
  26. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  27. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  28. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
  29. package/lib/types/admin-routes.d.ts +13 -0
  30. package/lib/types/admin-routes.d.ts.map +1 -1
  31. package/lib/types/client/index.d.ts.map +1 -1
  32. package/lib/types/client/locales.d.ts +204 -1
  33. package/lib/types/client/locales.d.ts.map +1 -1
  34. package/lib/types/client/wizard/GatewayWizard.d.ts +29 -0
  35. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -0
  36. package/lib/types/client/wizard/WizardCard.d.ts +30 -0
  37. package/lib/types/client/wizard/WizardCard.d.ts.map +1 -0
  38. package/lib/types/client/wizard/cards/MigrateCard.d.ts +15 -0
  39. package/lib/types/client/wizard/cards/MigrateCard.d.ts.map +1 -0
  40. package/lib/types/client/wizard/cards/PortForwardCard.d.ts +15 -0
  41. package/lib/types/client/wizard/cards/PortForwardCard.d.ts.map +1 -0
  42. package/lib/types/client/wizard/cards/ProxyCard.d.ts +19 -0
  43. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -0
  44. package/lib/types/client/wizard/cards/TokenCard.d.ts +20 -0
  45. package/lib/types/client/wizard/cards/TokenCard.d.ts.map +1 -0
  46. package/lib/types/client/wizard/register-wizard.d.ts +51 -0
  47. package/lib/types/client/wizard/register-wizard.d.ts.map +1 -0
  48. package/lib/types/client/wizard/wizard-css.d.ts +10 -0
  49. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -0
  50. package/lib/types/client-data/qr.d.ts +20 -0
  51. package/lib/types/client-data/qr.d.ts.map +1 -0
  52. package/lib/types/client-data/remote-status.d.ts +39 -2
  53. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  54. package/lib/types/client-data/settings-form.d.ts +7 -2
  55. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  56. package/lib/types/client-data/wizard.d.ts +247 -0
  57. package/lib/types/client-data/wizard.d.ts.map +1 -0
  58. package/lib/types/client-routes.d.ts +19 -0
  59. package/lib/types/client-routes.d.ts.map +1 -1
  60. package/lib/types/config.d.ts +27 -0
  61. package/lib/types/config.d.ts.map +1 -1
  62. package/lib/types/index.d.ts.map +1 -1
  63. package/lib/types/intercept.d.ts.map +1 -1
  64. package/lib/types/merge-streams.d.ts.map +1 -1
  65. package/lib/types/relay-client.d.ts +10 -0
  66. package/lib/types/relay-client.d.ts.map +1 -1
  67. package/lib/types/wizard/binary-io.d.ts +111 -0
  68. package/lib/types/wizard/binary-io.d.ts.map +1 -0
  69. package/lib/types/wizard/binary.d.ts +118 -0
  70. package/lib/types/wizard/binary.d.ts.map +1 -0
  71. package/lib/types/wizard/caddy-process.d.ts +101 -0
  72. package/lib/types/wizard/caddy-process.d.ts.map +1 -0
  73. package/lib/types/wizard/caddy.d.ts +53 -0
  74. package/lib/types/wizard/caddy.d.ts.map +1 -0
  75. package/lib/types/wizard/cloudflare.d.ts +112 -0
  76. package/lib/types/wizard/cloudflare.d.ts.map +1 -0
  77. package/lib/types/wizard/cloudflared.d.ts +71 -0
  78. package/lib/types/wizard/cloudflared.d.ts.map +1 -0
  79. package/lib/types/wizard/probe.d.ts +66 -0
  80. package/lib/types/wizard/probe.d.ts.map +1 -0
  81. package/lib/types/wizard/proxy-snippets.d.ts +60 -0
  82. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -0
  83. package/lib/types/wizard/routes.d.ts +152 -0
  84. package/lib/types/wizard/routes.d.ts.map +1 -0
  85. package/lib/types/wizard/source-detect.d.ts +53 -0
  86. package/lib/types/wizard/source-detect.d.ts.map +1 -0
  87. package/lib/types/wizard/status.d.ts +115 -0
  88. package/lib/types/wizard/status.d.ts.map +1 -0
  89. package/lib/types/wizard/token-store.d.ts +27 -0
  90. package/lib/types/wizard/token-store.d.ts.map +1 -0
  91. package/lib/types/wizard/tunnel-orchestrator.d.ts +90 -0
  92. package/lib/types/wizard/tunnel-orchestrator.d.ts.map +1 -0
  93. package/lib/types/wizard/verify.d.ts +97 -0
  94. package/lib/types/wizard/verify.d.ts.map +1 -0
  95. package/lib/wizard/binary-io.js +386 -0
  96. package/lib/wizard/binary-io.js.map +1 -0
  97. package/lib/wizard/binary.js +138 -0
  98. package/lib/wizard/binary.js.map +1 -0
  99. package/lib/wizard/caddy-process.js +162 -0
  100. package/lib/wizard/caddy-process.js.map +1 -0
  101. package/lib/wizard/caddy.js +80 -0
  102. package/lib/wizard/caddy.js.map +1 -0
  103. package/lib/wizard/cloudflare.js +334 -0
  104. package/lib/wizard/cloudflare.js.map +1 -0
  105. package/lib/wizard/cloudflared.js +158 -0
  106. package/lib/wizard/cloudflared.js.map +1 -0
  107. package/lib/wizard/probe.js +118 -0
  108. package/lib/wizard/probe.js.map +1 -0
  109. package/lib/wizard/proxy-snippets.js +112 -0
  110. package/lib/wizard/proxy-snippets.js.map +1 -0
  111. package/lib/wizard/routes.js +462 -0
  112. package/lib/wizard/routes.js.map +1 -0
  113. package/lib/wizard/source-detect.js +77 -0
  114. package/lib/wizard/source-detect.js.map +1 -0
  115. package/lib/wizard/status.js +94 -0
  116. package/lib/wizard/status.js.map +1 -0
  117. package/lib/wizard/token-store.js +71 -0
  118. package/lib/wizard/token-store.js.map +1 -0
  119. package/lib/wizard/tunnel-orchestrator.js +155 -0
  120. package/lib/wizard/tunnel-orchestrator.js.map +1 -0
  121. package/lib/wizard/verify.js +171 -0
  122. package/lib/wizard/verify.js.map +1 -0
  123. package/package.json +37 -37
  124. package/scripts/check-settings-form.mjs +5 -4
  125. package/scripts/check-wizard-ui.mjs +690 -0
  126. package/src/adapters/dsh-better-sidebar/index.ts +6 -2
  127. package/src/adapters/dsh-better-sidebar/methods.ts +3 -0
  128. package/src/admin-routes.ts +44 -2
  129. package/src/client/index.tsx +9 -0
  130. package/src/client/locales.ts +410 -2
  131. package/src/client/wizard/GatewayWizard.tsx +2220 -0
  132. package/src/client/wizard/WizardCard.tsx +81 -0
  133. package/src/client/wizard/cards/MigrateCard.tsx +42 -0
  134. package/src/client/wizard/cards/PortForwardCard.tsx +37 -0
  135. package/src/client/wizard/cards/ProxyCard.tsx +56 -0
  136. package/src/client/wizard/cards/TokenCard.tsx +81 -0
  137. package/src/client/wizard/register-wizard.ts +99 -0
  138. package/src/client/wizard/wizard-css.ts +948 -0
  139. package/src/client-data/qr.ts +284 -0
  140. package/src/client-data/remote-status.ts +128 -2
  141. package/src/client-data/settings-form.ts +12 -3
  142. package/src/client-data/wizard.ts +432 -0
  143. package/src/client-routes.ts +53 -0
  144. package/src/config.ts +87 -0
  145. package/src/index.ts +227 -2
  146. package/src/intercept.ts +43 -3
  147. package/src/merge-streams.ts +41 -12
  148. package/src/relay-client.ts +76 -0
  149. package/src/wizard/binary-io.ts +449 -0
  150. package/src/wizard/binary.ts +177 -0
  151. package/src/wizard/caddy-process.ts +228 -0
  152. package/src/wizard/caddy.ts +97 -0
  153. package/src/wizard/cloudflare.ts +413 -0
  154. package/src/wizard/cloudflared.ts +196 -0
  155. package/src/wizard/probe.ts +161 -0
  156. package/src/wizard/proxy-snippets.ts +128 -0
  157. package/src/wizard/routes.ts +537 -0
  158. package/src/wizard/source-detect.ts +87 -0
  159. package/src/wizard/status.ts +180 -0
  160. package/src/wizard/token-store.ts +73 -0
  161. package/src/wizard/tunnel-orchestrator.ts +197 -0
  162. package/src/wizard/verify.ts +236 -0
@@ -0,0 +1,690 @@
1
+ // Behaviour check for the gateway-wizard UI pure pieces (GW-10), driven
2
+ // against the REAL client-data modules (Node >= 23.6 type stripping, no
3
+ // build needed):
4
+ //
5
+ // - the step state machine: initialStep, configureReadiness (every path ×
6
+ // every refusal), publicUrlFrom, hostname/port validation, the pairing
7
+ // countdown, and the three verification lights;
8
+ // - the locale dictionaries: zh/en key sets identical, and EVERY wizard key
9
+ // the components use is present (the literal list below is the contract);
10
+ // - the snippet templates: client-data/wizard.ts (browser mirror) vs the
11
+ // host's BUILT lib/wizard/proxy-snippets.js — byte-identical on shared
12
+ // input, so the two cannot drift (the built lib because the src half
13
+ // transitively imports node builtins through config.js, which Node's
14
+ // strip-only loader cannot map; pnpm test runs after the build anyway);
15
+ // - the QR encoder at DECODER level: structural checks, format-info BCH,
16
+ // Reed–Solomon syndromes all-zero, and full data recovery — stronger
17
+ // than comparing against a memorized reference matrix.
18
+ //
19
+ // Run: node scripts/check-wizard-ui.mjs
20
+ import assert from 'node:assert/strict'
21
+ import {
22
+ caddyFailureCopy,
23
+ configureReadiness,
24
+ tunnelCheckCopy,
25
+ tunnelFailureCopy,
26
+ caddySnippetText,
27
+ deriveClientWizardLights,
28
+ deriveWizardLights,
29
+ formSeedOf,
30
+ WIZARD_CF_TOKEN_ROUTE,
31
+ WIZARD_CADDY_START_ROUTE,
32
+ WIZARD_CADDY_STATUS_ROUTE,
33
+ WIZARD_CADDY_STOP_ROUTE,
34
+ WIZARD_TUNNEL_CHECK_ROUTE,
35
+ WIZARD_TUNNEL_SETUP_ROUTE,
36
+ WIZARD_TUNNEL_STATUS_ROUTE,
37
+ WIZARD_TUNNEL_STOP_ROUTE,
38
+ WIZARD_TUNNEL_TOKEN_ROUTE,
39
+ initialStep,
40
+ isWizardConfigured,
41
+ isValidHostname,
42
+ isValidPort,
43
+ luckyStepTexts,
44
+ nginxSnippetText,
45
+ pairingSecondsLeft,
46
+ publicUrlFrom,
47
+ upstreamPortOf,
48
+ } from '../src/client-data/wizard.ts'
49
+ import { luckySteps, nginxSnippet, caddySnippet } from '../lib/wizard/proxy-snippets.js'
50
+ import { qrMatrix } from '../src/client-data/qr.ts'
51
+ import {
52
+ WIZARD_CF_TOKEN_ROUTE as HOST_CF_TOKEN_ROUTE,
53
+ WIZARD_CADDY_START_ROUTE as HOST_CADDY_START_ROUTE,
54
+ WIZARD_CADDY_STOP_ROUTE as HOST_CADDY_STOP_ROUTE,
55
+ WIZARD_CADDY_STATUS_ROUTE as HOST_CADDY_STATUS_ROUTE,
56
+ WIZARD_TUNNEL_TOKEN_ROUTE as HOST_TUNNEL_TOKEN_ROUTE,
57
+ WIZARD_TUNNEL_CHECK_ROUTE as HOST_TUNNEL_CHECK_ROUTE,
58
+ WIZARD_TUNNEL_SETUP_ROUTE as HOST_TUNNEL_SETUP_ROUTE,
59
+ WIZARD_TUNNEL_STATUS_ROUTE as HOST_TUNNEL_STATUS_ROUTE,
60
+ WIZARD_TUNNEL_STOP_ROUTE as HOST_TUNNEL_STOP_ROUTE,
61
+ } from '../lib/wizard/routes.js'
62
+
63
+ import { en, zh } from '../src/client/locales.ts'
64
+ import { registerGatewayWizard, WIZARD_BUNDLE_KEY } from '../src/client/wizard/register-wizard.ts'
65
+
66
+ let testChain = Promise.resolve()
67
+ function test(name, fn) {
68
+ testChain = testChain
69
+ .then(fn)
70
+ .then(() => { console.log(`ok - ${name}`) })
71
+ .catch((error) => {
72
+ console.error(`not ok - ${name}`)
73
+ throw error
74
+ })
75
+ return testChain
76
+ }
77
+
78
+ // ---- the step state machine -------------------------------------------------
79
+
80
+ await test('initialStep: a configured ingress opens on verify, off opens on pick', () => {
81
+ assert.equal(initialStep(undefined), 'pick')
82
+ assert.equal(initialStep({ ok: true, mode: 'off' }), 'pick')
83
+ for (const mode of ['caddy', 'external', 'cloudflared']) {
84
+ assert.equal(initialStep({ ok: true, mode }), 'verify')
85
+ }
86
+ })
87
+
88
+ await test('isWizardConfigured: distinguishes fresh install from configured host or client', () => {
89
+ const baseWizardStatus = {
90
+ ok: true, mode: 'off', domain: '', httpsPort: 443, publicUrl: '',
91
+ cfTokenConfigured: false, tunnelTokenConfigured: false, caddyRunning: false,
92
+ cloudflaredRunning: false, gatewayActualPort: 3088,
93
+ lastHostProbe: null, lastExternalVerification: null, lastForwardedSource: null,
94
+ }
95
+ assert.equal(
96
+ isWizardConfigured({
97
+ effectiveClient: false,
98
+ wizardStatus: baseWizardStatus,
99
+ pairedDeviceCount: 0,
100
+ hasSavedHostConfig: false,
101
+ tokenConfigured: false,
102
+ clientServerUrl: '',
103
+ clientState: undefined,
104
+ }),
105
+ false,
106
+ )
107
+ assert.equal(
108
+ isWizardConfigured({
109
+ effectiveClient: false,
110
+ wizardStatus: { ...baseWizardStatus, mode: 'external', publicUrl: 'https://dsh.example.com' },
111
+ pairedDeviceCount: 0,
112
+ hasSavedHostConfig: false,
113
+ tokenConfigured: false,
114
+ clientServerUrl: '',
115
+ clientState: undefined,
116
+ }),
117
+ true,
118
+ )
119
+ assert.equal(
120
+ isWizardConfigured({
121
+ effectiveClient: false,
122
+ wizardStatus: baseWizardStatus,
123
+ pairedDeviceCount: 1,
124
+ hasSavedHostConfig: false,
125
+ tokenConfigured: false,
126
+ clientServerUrl: '',
127
+ clientState: undefined,
128
+ }),
129
+ true,
130
+ )
131
+ assert.equal(
132
+ isWizardConfigured({
133
+ effectiveClient: false,
134
+ wizardStatus: baseWizardStatus,
135
+ pairedDeviceCount: 0,
136
+ hasSavedHostConfig: true,
137
+ tokenConfigured: false,
138
+ clientServerUrl: '',
139
+ clientState: undefined,
140
+ }),
141
+ true,
142
+ )
143
+ assert.equal(
144
+ isWizardConfigured({
145
+ effectiveClient: true,
146
+ wizardStatus: undefined,
147
+ pairedDeviceCount: 0,
148
+ hasSavedHostConfig: false,
149
+ tokenConfigured: false,
150
+ clientServerUrl: '',
151
+ clientState: undefined,
152
+ }),
153
+ false,
154
+ )
155
+ assert.equal(
156
+ isWizardConfigured({
157
+ effectiveClient: true,
158
+ wizardStatus: undefined,
159
+ pairedDeviceCount: 0,
160
+ hasSavedHostConfig: false,
161
+ tokenConfigured: true,
162
+ clientServerUrl: 'https://dsh.example.com',
163
+ clientState: 'connected',
164
+ }),
165
+ true,
166
+ )
167
+ })
168
+
169
+ await test('hostname/port validation mirrors the row layer', () => {
170
+ for (const ok of ['dsh.example.com', 'mybox', '192.168.1.10', 'a-b.cd']) {
171
+ assert.equal(isValidHostname(ok), true, ok)
172
+ }
173
+ for (const bad of ['https://x', 'x/y', 'a b', 'a\nb', 'x{}', 'x:443', '-x', 'x-', '']) {
174
+ assert.equal(isValidHostname(bad), false, bad)
175
+ }
176
+ assert.equal(isValidPort('443'), true)
177
+ assert.equal(isValidPort('1'), true)
178
+ assert.equal(isValidPort('65535'), true)
179
+ for (const bad of ['0', '65536', '-1', '4.5', 'abc', '', '0443 ']) {
180
+ assert.equal(isValidPort(bad), false, bad)
181
+ }
182
+ assert.equal(publicUrlFrom('dsh.example.com', 443), 'https://dsh.example.com')
183
+ assert.equal(publicUrlFrom('dsh.example.com', 8443), 'https://dsh.example.com:8443')
184
+ })
185
+
186
+ await test('configureReadiness: each path demands its own fields', () => {
187
+ const caddy = { path: 'caddy', form: { domain: 'dsh.example.com', httpsPort: '8443', cfToken: 'tok', serverUrl: '' } }
188
+ assert.deepEqual(configureReadiness(caddy.path, caddy.form), { ok: true, publicUrl: 'https://dsh.example.com:8443', httpsPort: 8443 })
189
+ assert.equal(configureReadiness('caddy', { ...caddy.form, cfToken: ' ' }).error, 'missing-token')
190
+ assert.equal(configureReadiness('cloudflared', { ...caddy.form, cfToken: '' }).error, 'missing-token')
191
+ assert.equal(configureReadiness('caddy', { ...caddy.form, domain: 'a b' }).error, 'bad-domain')
192
+ assert.equal(configureReadiness('caddy', { ...caddy.form, httpsPort: '0' }).error, 'bad-port')
193
+ // external: the public origin is the whole form.
194
+ assert.deepEqual(
195
+ configureReadiness('external', { domain: '', httpsPort: '', cfToken: '', serverUrl: 'https://dsh.example.com/' }),
196
+ { ok: true, publicUrl: 'https://dsh.example.com', httpsPort: 443 },
197
+ )
198
+ assert.equal(configureReadiness('external', { domain: '', httpsPort: '', cfToken: '', serverUrl: 'http://dsh.example.com' }).error, 'bad-server-url')
199
+ assert.equal(configureReadiness('external', { domain: '', httpsPort: '', cfToken: '', serverUrl: 'https://x/app' }).error, 'bad-server-url')
200
+ assert.equal(configureReadiness('external', { domain: '', httpsPort: '', cfToken: '', serverUrl: 'not a url' }).error, 'bad-server-url')
201
+ // lan: empty serverUrl is valid (LAN/Tailscale direct without public URL), or http/https origin.
202
+ assert.deepEqual(
203
+ configureReadiness('lan', { domain: '', httpsPort: '', cfToken: '', serverUrl: '' }),
204
+ { ok: true, publicUrl: '', httpsPort: 443 },
205
+ )
206
+ assert.deepEqual(
207
+ configureReadiness('lan', { domain: '', httpsPort: '', cfToken: '', serverUrl: 'http://192.168.1.10:3088/' }),
208
+ { ok: true, publicUrl: 'http://192.168.1.10:3088', httpsPort: 443 },
209
+ )
210
+ assert.equal(configureReadiness('lan', { domain: '', httpsPort: '', cfToken: '', serverUrl: 'ftp://192.168.1.10' }).error, 'bad-server-url')
211
+ })
212
+
213
+ await test('deriveClientWizardLights: reflects token and client connection state', () => {
214
+ assert.deepEqual(
215
+ deriveClientWizardLights({ tokenConfigured: false, clientState: undefined }),
216
+ { credential: 'pending', connection: 'pending', sync: 'pending' },
217
+ )
218
+ assert.deepEqual(
219
+ deriveClientWizardLights({ tokenConfigured: true, clientState: 'connected' }),
220
+ { credential: 'pass', connection: 'pass', sync: 'pass' },
221
+ )
222
+ assert.deepEqual(
223
+ deriveClientWizardLights({ tokenConfigured: false, clientState: 'revoked' }),
224
+ { credential: 'fail', connection: 'fail', sync: 'pending' },
225
+ )
226
+ assert.deepEqual(
227
+ deriveClientWizardLights({ tokenConfigured: true, clientState: 'unreachable' }),
228
+ { credential: 'pass', connection: 'fail', sync: 'pending' },
229
+ )
230
+ })
231
+
232
+ await test('formSeedOf: the configure forms seed from the wizard projection, not admin/status (GW-fix-3/M1)', () => {
233
+ // The projection carries the ingress fields; admin/status does not (GW-1
234
+ // strips them) — seeding from there would silently never backfill.
235
+ const configured = {
236
+ ok: true, mode: 'caddy', domain: 'dsh.example.com', httpsPort: 8443,
237
+ publicUrl: 'https://dsh.example.com:8443', cfTokenConfigured: true, tunnelTokenConfigured: false,
238
+ caddyRunning: false, cloudflaredRunning: false, gatewayActualPort: 4088,
239
+ lastHostProbe: null, lastExternalVerification: null, lastForwardedSource: null,
240
+ }
241
+ assert.deepEqual(formSeedOf(configured), {
242
+ domain: 'dsh.example.com', httpsPort: '8443', serverUrl: 'https://dsh.example.com:8443', path: 'caddy',
243
+ })
244
+ // An unconfigured projection seeds nothing (the forms keep the user's
245
+ // input) and names no path.
246
+ assert.deepEqual(formSeedOf({ ...configured, mode: 'off', domain: '', httpsPort: 443, publicUrl: '' }), {
247
+ domain: '', httpsPort: '', serverUrl: '', path: undefined,
248
+ })
249
+ assert.deepEqual(formSeedOf(undefined).path, undefined)
250
+ })
251
+
252
+ await test('upstreamPortOf: the gateway ACTUAL port wins, 3088 is the fallback (GW-fix-3/M2)', () => {
253
+ assert.equal(upstreamPortOf(undefined), 3088)
254
+ assert.equal(upstreamPortOf({ ok: true, mode: 'caddy', domain: '', httpsPort: 443, publicUrl: '', cfTokenConfigured: false, tunnelTokenConfigured: false, caddyRunning: false, cloudflaredRunning: false, gatewayActualPort: null, lastHostProbe: null, lastExternalVerification: null, lastForwardedSource: null }), 3088)
255
+ assert.equal(upstreamPortOf({ ok: true, mode: 'caddy', domain: '', httpsPort: 443, publicUrl: '', cfTokenConfigured: false, tunnelTokenConfigured: false, caddyRunning: false, cloudflaredRunning: false, gatewayActualPort: 4088, lastHostProbe: null, lastExternalVerification: null, lastForwardedSource: null }), 4088)
256
+ // A junk actual port falls back too.
257
+ assert.equal(upstreamPortOf({ ok: true, mode: 'caddy', domain: '', httpsPort: 443, publicUrl: '', cfTokenConfigured: false, tunnelTokenConfigured: false, caddyRunning: false, cloudflaredRunning: false, gatewayActualPort: 0, lastHostProbe: null, lastExternalVerification: null, lastForwardedSource: null }), 3088)
258
+ })
259
+
260
+ await test('deriveWizardLights: amber before trying, red on wrong shapes, pass only on pass', () => {
261
+ // Nothing started: gateway reflects reachability, the rest stay amber.
262
+ assert.deepEqual(
263
+ deriveWizardLights({ gatewayReachable: false, poll: undefined }),
264
+ { gateway: 'fail', publicUrl: 'pending', pairing: 'pending' },
265
+ )
266
+ assert.deepEqual(
267
+ deriveWizardLights({ gatewayReachable: true, poll: undefined }),
268
+ { gateway: 'pass', publicUrl: 'pending', pairing: 'pending' },
269
+ )
270
+ // Pending verdicts keep everything amber-but-running.
271
+ for (const result of ['not-started', 'pending', 'stale', 'code-expired']) {
272
+ const lights = deriveWizardLights({ gatewayReachable: true, poll: { result } })
273
+ assert.equal(lights.publicUrl, 'pending', result)
274
+ assert.equal(lights.pairing, 'pending', result)
275
+ }
276
+ assert.deepEqual(
277
+ deriveWizardLights({ gatewayReachable: true, poll: { result: 'pass', deviceId: 'd1', at: 1 } }),
278
+ { gateway: 'pass', publicUrl: 'pass', pairing: 'pass' },
279
+ )
280
+ // A wrong-shape probe answer fails the public light but never pairing.
281
+ const lights = deriveWizardLights({ gatewayReachable: true, poll: { result: 'cookie-insecure', deviceId: 'd1' } })
282
+ assert.deepEqual(lights, { gateway: 'pass', publicUrl: 'fail', pairing: 'fail' })
283
+ // When startResult is supplied, a successful start probe turns publicUrl green immediately,
284
+ // while a failed probe turns publicUrl red immediately.
285
+ assert.deepEqual(
286
+ deriveWizardLights({
287
+ gatewayReachable: true,
288
+ poll: { result: 'pending' },
289
+ startResult: { ok: true, pairingCode: 'ABCD1234', publicUrl: 'https://dsh.example.com', expiresAt: 1000 },
290
+ }),
291
+ { gateway: 'pass', publicUrl: 'pass', pairing: 'pending' },
292
+ )
293
+ assert.deepEqual(
294
+ deriveWizardLights({
295
+ gatewayReachable: true,
296
+ poll: undefined,
297
+ startResult: { ok: false, reason: 'probe-failed', detail: { kind: 'reached', root: 'unexpected-401', admin: 'gateway-admin-403' } },
298
+ }),
299
+ { gateway: 'pass', publicUrl: 'fail', pairing: 'pending' },
300
+ )
301
+ })
302
+
303
+ await test('caddyFailureCopy: every wire error maps to its locale key (GW-13a)', () => {
304
+ assert.equal(caddyFailureCopy('missing-token').key, 'wizard.caddy.failMissingToken')
305
+ assert.equal(caddyFailureCopy('already-running').key, 'wizard.caddy.failAlreadyRunning')
306
+ assert.deepEqual(caddyFailureCopy('bad-config', 'bad-domain'), { key: 'wizard.caddy.failBadConfig', params: { detail: 'bad-domain' } })
307
+ const download = caddyFailureCopy('download-failed', undefined, 'https://caddyserver.com/download')
308
+ assert.equal(download.key, 'wizard.caddy.failDownload')
309
+ assert.equal(download.installUrl, 'https://caddyserver.com/download')
310
+ assert.equal(caddyFailureCopy('validate-failed').key, 'wizard.caddy.failValidate')
311
+ assert.equal(caddyFailureCopy('spawn-failed').key, 'wizard.caddy.failSpawn')
312
+ assert.equal(caddyFailureCopy('something-new').key, 'wizard.caddy.failSpawn', 'unknown codes degrade to the generic failure')
313
+ })
314
+
315
+ await test('the route constants mirror the host routes (GW-13a)', () => {
316
+ assert.equal(WIZARD_CF_TOKEN_ROUTE, HOST_CF_TOKEN_ROUTE)
317
+ assert.equal(WIZARD_CADDY_START_ROUTE, HOST_CADDY_START_ROUTE)
318
+ assert.equal(WIZARD_CADDY_STOP_ROUTE, HOST_CADDY_STOP_ROUTE)
319
+ assert.equal(WIZARD_CADDY_STATUS_ROUTE, HOST_CADDY_STATUS_ROUTE)
320
+ assert.equal(WIZARD_TUNNEL_TOKEN_ROUTE, HOST_TUNNEL_TOKEN_ROUTE)
321
+ })
322
+
323
+ await test('tunnelFailureCopy / tunnelCheckCopy: every reason maps to its locale key (GW-13b)', () => {
324
+ for (const [reason, key] of [
325
+ ['tunnel-conflict', 'wizard.tunnel.conflict'],
326
+ ['zone-not-found', 'wizard.tunnel.zoneNotFound'],
327
+ ['dns-conflict', 'wizard.tunnel.dnsConflict'],
328
+ ['auth', 'wizard.tunnel.auth'],
329
+ ['network', 'wizard.tunnel.network'],
330
+ ['no-token', 'wizard.tunnel.noToken'],
331
+ ['bad-domain', 'wizard.tunnel.badDomain'],
332
+ ['spawn-failed', 'wizard.tunnel.spawnFailed'],
333
+ ]) {
334
+ const copy = tunnelFailureCopy(reason, 'd')
335
+ assert.equal(copy.key, key, reason)
336
+ if (reason === 'spawn-failed') assert.deepEqual(copy.params, { detail: 'd' })
337
+ if (reason === 'dns-conflict') assert.deepEqual(copy.params, { detail: 'd' })
338
+ }
339
+ // Unknown reasons degrade to the generic API failure carrying the reason.
340
+ assert.deepEqual(tunnelFailureCopy('anything-new'), { key: 'wizard.tunnel.api', params: { detail: 'anything-new' } })
341
+ assert.deepEqual(tunnelCheckCopy({ ok: true, onCloudflare: true, zoneId: 'z' }), { key: 'wizard.check.okOnCf', tone: 'ok' })
342
+ assert.equal(tunnelCheckCopy({ ok: true, onCloudflare: false, zoneId: null }).tone, 'error')
343
+ assert.equal(tunnelCheckCopy({ ok: false, reason: 'network' }).key, 'wizard.check.failed')
344
+ })
345
+
346
+ await test('the tunnel route constants mirror the host routes (GW-13b)', () => {
347
+ assert.equal(WIZARD_TUNNEL_CHECK_ROUTE, HOST_TUNNEL_CHECK_ROUTE)
348
+ assert.equal(WIZARD_TUNNEL_SETUP_ROUTE, HOST_TUNNEL_SETUP_ROUTE)
349
+ assert.equal(WIZARD_TUNNEL_STATUS_ROUTE, HOST_TUNNEL_STATUS_ROUTE)
350
+ assert.equal(WIZARD_TUNNEL_STOP_ROUTE, HOST_TUNNEL_STOP_ROUTE)
351
+ })
352
+
353
+ await test('the pairing countdown counts whole seconds, never negative', () => {
354
+ const now = 1_000_000
355
+ assert.equal(pairingSecondsLeft(now + 59_500, now), 60)
356
+ assert.equal(pairingSecondsLeft(now + 1000, now), 1)
357
+ assert.equal(pairingSecondsLeft(now, now), 0)
358
+ assert.equal(pairingSecondsLeft(now - 5000, now), 0)
359
+ })
360
+
361
+ // ---- the locale dictionaries --------------------------------------------------
362
+
363
+ const WIZARD_KEYS = [
364
+ 'wizard.title', 'wizard.subtitle', 'wizard.off',
365
+ 'wizard.step.pick', 'wizard.step.configure', 'wizard.step.verify',
366
+ 'wizard.path.caddy', 'wizard.path.caddyDesc', 'wizard.path.external', 'wizard.path.externalDesc',
367
+ 'wizard.path.cloudflared', 'wizard.path.cloudflaredDesc',
368
+ 'wizard.next', 'wizard.back', 'wizard.domain', 'wizard.domainHint', 'wizard.httpsPort',
369
+ 'wizard.cfToken', 'wizard.cfTokenHint', 'wizard.cfTokenHowTo', 'wizard.serverUrl', 'wizard.serverUrlHint',
370
+ 'wizard.proxySnippet', 'wizard.badDomain', 'wizard.badPort', 'wizard.missingToken', 'wizard.badServerUrl',
371
+ 'wizard.verify.gateway', 'wizard.verify.public', 'wizard.verify.pairing',
372
+ 'wizard.light.pass', 'wizard.light.pending', 'wizard.light.fail',
373
+ 'wizard.startVerify', 'wizard.reverify', 'wizard.pairingCode', 'wizard.pairingSeconds', 'wizard.qrHint',
374
+ 'wizard.verifyPass', 'wizard.verifyCookieInsecure', 'wizard.verifyExpired', 'wizard.verifyStale',
375
+ 'wizard.verifyPending', 'wizard.verifyNotStarted',
376
+ 'wizard.startFailed', 'wizard.startFailedProbe', 'wizard.startFailedRuntime', 'wizard.startFailedGateway',
377
+ 'wizard.startFailedPair', 'wizard.startFailedUrl',
378
+ 'wizard.remoteOnly', 'wizard.notWired',
379
+ 'wizard.card.proxyTitle', 'wizard.card.proxyCheck', 'wizard.card.tokenTitle', 'wizard.card.copy',
380
+ 'wizard.card.copied', 'wizard.card.close',
381
+ 'wizard.card.tokenStep1', 'wizard.card.tokenStep2', 'wizard.card.tokenStep3', 'wizard.card.tokenStep4',
382
+ 'wizard.card.tokenStep5', 'wizard.card.tokenStep6', 'wizard.card.tokenStep7', 'wizard.card.tokenStep8',
383
+ 'wizard.card.tokenPerms', 'wizard.card.tokenPermDns', 'wizard.card.tokenPermDnsWhy',
384
+ 'wizard.card.tokenPermTunnel', 'wizard.card.tokenPermTunnelWhy',
385
+ 'wizard.card.tokenPermAccount', 'wizard.card.tokenPermAccountWhy',
386
+ 'wizard.card.tokenPermZoneRead', 'wizard.card.tokenPermZoneReadWhy',
387
+ 'wizard.card.tokenPaste', 'wizard.card.tokenUse',
388
+ 'wizard.ingress',
389
+ 'wizard.caddy.start', 'wizard.caddy.stop', 'wizard.caddy.running', 'wizard.caddy.stopped',
390
+ 'wizard.caddy.neverStarted', 'wizard.caddy.startOk', 'wizard.caddy.startFailed',
391
+ 'wizard.caddy.failDownload', 'wizard.caddy.failValidate', 'wizard.caddy.failAlreadyRunning',
392
+ 'wizard.caddy.failMissingToken', 'wizard.caddy.failNoDomain', 'wizard.caddy.failBadConfig',
393
+ 'wizard.caddy.failSpawn',
394
+ 'wizard.token.saved', 'wizard.token.saveFailed',
395
+ 'wizard.tunnel.setup', 'wizard.tunnel.stop', 'wizard.tunnel.running', 'wizard.tunnel.stopped',
396
+ 'wizard.tunnel.neverStarted', 'wizard.tunnel.ok', 'wizard.tunnel.conflict', 'wizard.tunnel.useExisting',
397
+ 'wizard.tunnel.zoneNotFound', 'wizard.tunnel.dnsConflict', 'wizard.tunnel.auth', 'wizard.tunnel.network', 'wizard.tunnel.api',
398
+ 'wizard.tunnel.spawnFailed', 'wizard.tunnel.noToken', 'wizard.tunnel.badDomain', 'wizard.tunnel.checking',
399
+ 'wizard.check.okOnCf', 'wizard.check.okNotOnCf', 'wizard.check.failed',
400
+ 'wizard.card.migrateTitle', 'wizard.card.migrateRecheck',
401
+ 'wizard.card.migrateStep1', 'wizard.card.migrateStep2', 'wizard.card.migrateStep3',
402
+ 'wizard.card.migrateStep4', 'wizard.card.migrateStep5', 'wizard.card.migrateStep6',
403
+ 'wizard.card.portForwardTitle',
404
+ 'wizard.card.portForwardStep1', 'wizard.card.portForwardStep2', 'wizard.card.portForwardStep3',
405
+ 'wizard.card.portForwardStep4',
406
+ 'wizard.portForwardEntry', 'wizard.migrateEntry', 'wizard.caddy.starting',
407
+ 'wizard.overview.roleLabel', 'wizard.overview.gatewayLabel', 'wizard.overview.gatewayReady',
408
+ 'wizard.overview.gatewayDown', 'wizard.overview.ingressLabel', 'wizard.overview.devicesLabel',
409
+ 'wizard.overview.devicesSummary', 'wizard.overview.devicesNone', 'wizard.overview.clientCredLabel',
410
+ 'wizard.overview.clientConnLabel', 'wizard.overview.clientServerLabel', 'wizard.overview.startSetup',
411
+ 'wizard.overview.reconfigure', 'wizard.overview.pairPhone', 'wizard.overview.pairDesktop',
412
+ 'wizard.overview.clientDetail', 'wizard.overview.emptyIntro',
413
+ 'wizard.modal.title', 'wizard.modal.stepTag', 'wizard.modal.step1Desc', 'wizard.modal.step2Desc',
414
+ 'wizard.modal.step3Desc', 'wizard.modal.selectedPlan', 'wizard.modal.cancel',
415
+ 'wizard.modal.saveAndNext', 'wizard.modal.finish',
416
+ 'wizard.light.probing', 'wizard.overview.manageDevices',
417
+ 'wizard.devicesModal.title', 'wizard.devicesModal.subtitle', 'wizard.devicesModal.retest',
418
+ ]
419
+
420
+ // The cards' step counts are part of the walkthrough contract (GW-11).
421
+ const CARD_STEP_COUNTS = {
422
+ tokenStep: 8,
423
+ migrateStep: 6,
424
+ portForwardStep: 4,
425
+ }
426
+ for (const [prefix, expected] of Object.entries(CARD_STEP_COUNTS)) {
427
+ const keys = Object.keys(zh).filter((key) => key.startsWith(`wizard.card.${prefix}`))
428
+ assert.equal(keys.length, expected, `${prefix} has exactly ${expected} steps, got ${keys.join(', ')}`)
429
+ for (let i = 1; i <= expected; i++) {
430
+ assert.ok(keys.includes(`wizard.card.${prefix}${i}`), `missing step ${prefix}${i}`)
431
+ }
432
+ }
433
+ // The placeholder interpolation the port-forward steps promise.
434
+ assert.ok(zh['wizard.card.portForwardStep3'].includes('{port}'), 'step 3 interpolates the port')
435
+ assert.ok(en['wizard.card.portForwardStep3'].includes('{port}'), 'step 3 interpolates the port (en)')
436
+
437
+ await test('zh/en carry identical key sets, and every wizard key exists in both', () => {
438
+ assert.deepEqual(Object.keys(zh).sort(), Object.keys(en).sort(), 'zh is the key-set source of truth')
439
+ for (const key of WIZARD_KEYS) {
440
+ assert.ok(key in zh, `zh missing ${key}`)
441
+ assert.ok(key in en, `en missing ${key}`)
442
+ assert.equal(typeof zh[key], 'string')
443
+ assert.equal(typeof en[key], 'string')
444
+ }
445
+ })
446
+
447
+ // ---- the snippet templates: browser mirror == host half ------------------------
448
+
449
+ const SNIPPET_INPUT = { domain: 'dsh.example.com', upstreamHost: '127.0.0.1', upstreamPort: 4088 }
450
+
451
+ await test('the browser snippet mirrors are byte-identical to the host templates', () => {
452
+ assert.equal(nginxSnippetText(SNIPPET_INPUT), nginxSnippet(SNIPPET_INPUT).text)
453
+ assert.equal(caddySnippetText(SNIPPET_INPUT), caddySnippet(SNIPPET_INPUT).text)
454
+ assert.deepEqual(luckyStepTexts(SNIPPET_INPUT), luckySteps(SNIPPET_INPUT).steps)
455
+ // The mirrors refuse the same inputs the host refuses.
456
+ for (const bad of [{ ...SNIPPET_INPUT, domain: 'a b' }, { ...SNIPPET_INPUT, upstreamHost: 'x/y' }, { ...SNIPPET_INPUT, upstreamPort: 0 }]) {
457
+ assert.equal(nginxSnippetText(bad), null)
458
+ assert.equal(caddySnippetText(bad), null)
459
+ assert.equal(luckyStepTexts(bad), null)
460
+ }
461
+ })
462
+
463
+ // ---- the QR encoder, judged at decoder level ------------------------------------
464
+
465
+ // Galois field helpers for the syndrome check (same field as the encoder).
466
+ const EXP = new Uint8Array(512)
467
+ const LOG = new Uint8Array(256)
468
+ {
469
+ let x = 1
470
+ for (let i = 0; i < 255; i++) {
471
+ EXP[i] = x
472
+ LOG[x] = i
473
+ x <<= 1
474
+ if (x & 0x100) x ^= 0x11d
475
+ }
476
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]
477
+ }
478
+ const gfMul = (a, b) => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]])
479
+
480
+ /** syndromes S_i = C(α^i), i in 0..n-1 — all zero ⇔ valid RS codeword. */
481
+ function syndromes(codewords, eccLen) {
482
+ const out = []
483
+ for (let i = 0; i < eccLen; i++) {
484
+ let sum = 0
485
+ for (const byte of codewords) sum = gfMul(sum, EXP[i]) ^ byte
486
+ out.push(sum)
487
+ }
488
+ return out
489
+ }
490
+
491
+ const DATA_CODEWORDS = { 21: 19, 25: 34, 29: 55, 33: 80, 37: 108 }
492
+ const ECC_CODEWORDS = { 21: 7, 25: 10, 29: 15, 33: 20, 37: 26 }
493
+
494
+ await test('QR: every version encodes a decodable symbol (RS + format + data recovery)', () => {
495
+ for (const text of ['https://dsh.example.com', 'HELLO', 'x'.repeat(106), 'a']) {
496
+ const matrix = qrMatrix(text)
497
+ assert.ok(matrix !== null, `qrMatrix(${text.slice(0, 12)}…) must encode`)
498
+ const n = matrix.length
499
+ assert.ok([21, 25, 29, 33, 37].includes(n), `size ${n} is a known version`)
500
+
501
+ // Structure: the three finders + separators (top-left corner sampling),
502
+ // timing alternation, the dark module.
503
+ // The standard 7×7 finder, verbatim: solid border, hollow ring, 3×3 core.
504
+ const FINDER = [
505
+ [1, 1, 1, 1, 1, 1, 1],
506
+ [1, 0, 0, 0, 0, 0, 1],
507
+ [1, 0, 1, 1, 1, 0, 1],
508
+ [1, 0, 1, 1, 1, 0, 1],
509
+ [1, 0, 1, 1, 1, 0, 1],
510
+ [1, 0, 0, 0, 0, 0, 1],
511
+ [1, 1, 1, 1, 1, 1, 1],
512
+ ]
513
+ for (const [r0, c0] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
514
+ for (let dr = 0; dr < 7; dr++) {
515
+ for (let dc = 0; dc < 7; dc++) {
516
+ assert.equal(matrix[r0 + dr][c0 + dc], FINDER[dr][dc] === 1, `finder ${r0},${c0} +${dr},${dc}`)
517
+ }
518
+ }
519
+ }
520
+ for (let i = 8; i < n - 8; i++) assert.equal(matrix[6][i], i % 2 === 0, `timing row ${i}`)
521
+ for (let i = 8; i < n - 8; i++) assert.equal(matrix[i][6], i % 2 === 0, `timing col ${i}`)
522
+ assert.equal(matrix[n - 8][8], true, 'the fixed dark module')
523
+
524
+ // Format info: both copies agree and decode to (L, mask 0).
525
+ // ISO/IEC 18004 Figure 25: bit 14 (MSB) is at (8, 0) / (n - 1, 8) and
526
+ // bit 0 (LSB) is at (0, 8) / (8, n - 1).
527
+ const decodeFormat = (bits) => {
528
+ let value = 0
529
+ for (let i = 0; i < 15; i++) value |= (bits[i] ? 1 : 0) << i
530
+ assert.equal(value, 0x77c4, 'ISO/IEC 18004 Table C.1 format word for L + mask 0')
531
+ const unmasked = value ^ 0x5412
532
+ // BCH check: re-encode the 5 data bits and compare.
533
+ const data = unmasked >> 10
534
+ let rem = data << 10
535
+ for (let i = 4; i >= 0; i--) if ((rem >>> (i + 10)) & 1) rem ^= 0x537 << i
536
+ assert.equal(((data << 10) | rem) ^ 0x5412, value, 'format BCH is self-consistent')
537
+ assert.equal(data >> 3, 0b01, 'ECC level L')
538
+ assert.equal(data & 0b111, 0, 'mask 0')
539
+ }
540
+ const formatBits = []
541
+ for (let i = 0; i <= 5; i++) formatBits[14 - i] = matrix[8][i]
542
+ formatBits[8] = matrix[8][7]
543
+ formatBits[7] = matrix[8][8]
544
+ formatBits[6] = matrix[7][8]
545
+ for (let i = 9; i <= 14; i++) formatBits[14 - i] = matrix[14 - i][8]
546
+ decodeFormat(formatBits)
547
+ const formatBits2 = []
548
+ for (let i = 0; i <= 6; i++) formatBits2[14 - i] = matrix[n - 1 - i][8]
549
+ for (let i = 7; i <= 14; i++) formatBits2[14 - i] = matrix[8][n - 15 + i]
550
+ assert.deepEqual(formatBits2, formatBits, 'both format copies agree')
551
+
552
+ // Collect the DATA positions (the zigzag minus every reserved cell),
553
+ // then read the codeword bits in placement order.
554
+ const reserved = Array.from({ length: n }, () => new Array(n).fill(false))
555
+ const mark = (r, c) => { if (r >= 0 && r < n && c >= 0 && c < n) reserved[r][c] = true }
556
+ for (let i = 0; i < 9; i++) { mark(8, i); mark(i, 8) }
557
+ for (let i = 0; i < 8; i++) { mark(n - 1 - i, 8); mark(8, n - 1 - i) }
558
+ mark(n - 8, 8)
559
+ // The finder blocks AND their one-module separators: the encoder
560
+ // writes the full (dr, dc ∈ [-1, 7]) window of each corner.
561
+ for (const [r0, c0] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
562
+ for (let dr = -1; dr <= 7; dr++) {
563
+ for (let dc = -1; dc <= 7; dc++) mark(r0 + dr, c0 + dc)
564
+ }
565
+ }
566
+ for (let i = 0; i < n; i++) { mark(6, i); mark(i, 6) }
567
+ // Alignment patterns (versions 2-5): 5×5 at each center-pair point,
568
+ // SKIPPING any center whose pattern would touch a finder window — the
569
+ // same rule the encoder applies (standard: those positions do not get
570
+ // an alignment pattern at all).
571
+ const CENTERS = { 25: [6, 18], 29: [6, 22], 33: [6, 26], 37: [6, 30] }[n]
572
+ if (CENTERS !== undefined) {
573
+ const windows = [[0, 0], [0, n - 7], [n - 7, 0]].map(([r0, c0]) => [r0 - 1, r0 + 7, c0 - 1, c0 + 7])
574
+ for (const r of CENTERS) {
575
+ for (const c of CENTERS) {
576
+ const overlaps = windows.some(([r1, r2, c1, c2]) => r >= r1 && r <= r2 && c >= c1 && c <= c2)
577
+ if (overlaps) continue
578
+ for (let dr = -2; dr <= 2; dr++) {
579
+ for (let dc = -2; dc <= 2; dc++) mark(r + dr, c + dc)
580
+ }
581
+ }
582
+ }
583
+ }
584
+
585
+ // Collect the data-bit stream AND undo mask 0 ((row+col) even flips) —
586
+ // the RS codewords live UNDER the mask; a decoder un-masks first.
587
+ const stream = []
588
+ let inc = -1
589
+ let row = n - 1
590
+ for (let right = n - 1; right >= 0; right -= 2) {
591
+ if (right === 6) right = 5
592
+ while (true) {
593
+ for (let c = 0; c < 2; c++) {
594
+ if (!reserved[row][right - c]) {
595
+ const bit = matrix[row][right - c]
596
+ stream.push((row + (right - c)) % 2 === 0 ? !bit : bit)
597
+ }
598
+ }
599
+ row += inc
600
+ if (row < 0 || row >= n) { row -= inc; inc = -inc; break }
601
+ }
602
+ }
603
+ const codewords = []
604
+ for (let i = 0; i + 8 <= stream.length; i += 8) {
605
+ let byte = 0
606
+ for (let j = 0; j < 8; j++) byte = (byte << 1) | (stream[i + j] ? 1 : 0)
607
+ codewords.push(byte)
608
+ }
609
+ const eccLen = ECC_CODEWORDS[n]
610
+ const dataLen = DATA_CODEWORDS[n]
611
+ assert.equal(codewords.length, dataLen + eccLen, 'the stream length matches the version')
612
+
613
+ // RS syndromes all zero: the ECC codewords are mathematically correct,
614
+ // so ANY standard reader accepts the symbol.
615
+ for (const s of syndromes(codewords, eccLen)) assert.equal(s, 0, 'RS syndrome')
616
+
617
+ // Data recovery: mode 0100 + 8-bit count + the original bytes.
618
+ let bitIndex = 0
619
+ const readBit = () => {
620
+ const byte = codewords[bitIndex >> 3]
621
+ const bit = (byte >> (7 - (bitIndex & 7))) & 1
622
+ bitIndex += 1
623
+ return bit
624
+ }
625
+ const readBits = (count) => {
626
+ let value = 0
627
+ for (let i = 0; i < count; i++) value = (value << 1) | readBit()
628
+ return value
629
+ }
630
+ assert.equal(readBits(4), 0b0100, 'byte mode')
631
+ const length = readBits(8)
632
+ const payload = new TextDecoder().decode(Uint8Array.from({ length }, () => readBits(8)))
633
+ assert.equal(payload, text, `data round-trips: ${text.slice(0, 24)}…`)
634
+ }
635
+
636
+ // Overlong text refuses instead of mis-encoding.
637
+ assert.equal(qrMatrix('x'.repeat(107)), null)
638
+ })
639
+
640
+ await test('registerGatewayWizard: mounts on plugins.bundle.config (top of bundle detail page, absent on row detail page)', () => {
641
+ const slotsInjected = []
642
+ const slotsRegistered = []
643
+ let formsCallback
644
+ const ctx = {
645
+ effect: () => () => {},
646
+ inject(services, cb) {
647
+ if (services.includes('configForms')) formsCallback = cb
648
+ },
649
+ }
650
+ const stubWizard = () => null
651
+ registerGatewayWizard(ctx, stubWizard)
652
+ assert.equal(typeof formsCallback, 'function', 'registers through lazy configForms inject')
653
+
654
+ const formsEffects = []
655
+ formsCallback({
656
+ effect(producer, label) {
657
+ formsEffects.push({ label, producer })
658
+ return () => {}
659
+ },
660
+ configForms: {
661
+ get: () => ({
662
+ getSnapshot: () => ({ status: 'ready', value: {}, base: {}, user: {}, revision: 1, writable: true }),
663
+ subscribe: () => () => {},
664
+ mutate: async () => true,
665
+ }),
666
+ },
667
+ slots: {
668
+ inject(name, factory) {
669
+ slotsInjected.push(name)
670
+ factory()
671
+ return () => {}
672
+ },
673
+ register(options, component) {
674
+ slotsRegistered.push({ options, component })
675
+ return () => {}
676
+ },
677
+ },
678
+ })
679
+ const sectionEffect = formsEffects.find((entry) => entry.label === 'dsh-zen-remote: wizard section')
680
+ assert.ok(sectionEffect !== undefined, 'wizard section effect registered')
681
+ sectionEffect.producer()
682
+ assert.deepEqual(slotsInjected, ['plugins.bundle.config'])
683
+ assert.equal(slotsRegistered.length, 1)
684
+ assert.equal(slotsRegistered[0].options.name, 'plugins.bundle.config')
685
+ assert.equal(slotsRegistered[0].options.key, WIZARD_BUNDLE_KEY)
686
+ assert.equal(slotsRegistered[0].options.key, 'dsh-zen-remote')
687
+ assert.equal(slotsRegistered[0].component, stubWizard)
688
+ })
689
+
690
+ console.log('check-wizard-ui: ok')