@mycadet/assistant 0.12.10 → 0.13.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.
Files changed (180) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  3. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  7. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  8. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  9. package/openapi.yaml +2 -708
  10. package/package.json +2 -3
  11. package/scripts/sync-web-search-catalog.ts +1 -2
  12. package/src/__tests__/cloud-profile-guard.test.ts +41 -5
  13. package/src/__tests__/config-schema.test.ts +6 -38
  14. package/src/__tests__/conversation-queue.test.ts +45 -6
  15. package/src/__tests__/db-readiness-http-gate.test.ts +0 -6
  16. package/src/__tests__/persist-onboarding-artifacts.test.ts +0 -1
  17. package/src/__tests__/persona-resolver.test.ts +0 -51
  18. package/src/__tests__/provider-cloud-proxy-integration.test.ts +2 -110
  19. package/src/__tests__/run-due-schedules.test.ts +0 -4
  20. package/src/__tests__/schedule-store.test.ts +0 -12
  21. package/src/__tests__/stt-stream-session.test.ts +4 -4
  22. package/src/__tests__/telephony-tts-guard.test.ts +2 -50
  23. package/src/__tests__/voice-config-update.test.ts +12 -167
  24. package/src/__tests__/web-search-catalog-parity.test.ts +0 -2
  25. package/src/__tests__/workspace-migration-130-speech-mode-to-provider.test.ts +9 -7
  26. package/src/__tests__/workspace-migration-132-web-search-mode-to-provider.test.ts +6 -6
  27. package/src/calls/__tests__/voice-session-bridge.test.ts +1 -4
  28. package/src/calls/resolve-call-tts-provider.ts +6 -25
  29. package/src/calls/telephony-credential-preflight.ts +0 -10
  30. package/src/calls/telephony-tts-capability.ts +1 -17
  31. package/src/cli/commands/tts.ts +1 -2
  32. package/src/cli/index.help.ts +0 -2
  33. package/src/cli/program.ts +0 -2
  34. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +4 -42
  35. package/src/config/schemas/__tests__/stt.test.ts +10 -14
  36. package/src/config/schemas/llm.ts +3 -5
  37. package/src/config/schemas/services.ts +5 -6
  38. package/src/config/schemas/stt.ts +1 -3
  39. package/src/config/schemas/tts.ts +1 -30
  40. package/src/daemon/daemon-readiness.ts +4 -29
  41. package/src/daemon/lifecycle.ts +0 -31
  42. package/src/daemon/providers-setup.ts +0 -8
  43. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  44. package/src/heartbeat/heartbeat-service.ts +1 -66
  45. package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +1 -9
  46. package/src/live-voice/__tests__/live-voice-credential-preflight.test.ts +0 -44
  47. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +4 -6
  48. package/src/live-voice/__tests__/live-voice-stt.test.ts +1 -1
  49. package/src/live-voice/live-voice-credential-preflight.ts +9 -33
  50. package/src/live-voice/live-voice-session.ts +10 -6
  51. package/src/live-voice/live-voice-tts.ts +1 -8
  52. package/src/plugins/defaults/memory/worker.ts +3 -3
  53. package/src/prompts/persona-resolver.ts +0 -35
  54. package/src/providers/__tests__/search-provider-catalog.test.ts +5 -8
  55. package/src/providers/__tests__/voice-error-copy.test.ts +9 -10
  56. package/src/providers/search-provider-catalog.ts +7 -39
  57. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +3 -47
  58. package/src/providers/speech-to-text/__tests__/resolve.test.ts +6 -372
  59. package/src/providers/speech-to-text/provider-catalog.ts +2 -73
  60. package/src/providers/speech-to-text/resolve.ts +3 -120
  61. package/src/runtime/http-router-types.ts +1 -3
  62. package/src/runtime/http-server.ts +1 -1
  63. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +13 -11
  64. package/src/runtime/routes/__tests__/tts-routes.test.ts +2 -6
  65. package/src/runtime/routes/conversation-query-routes.ts +0 -96
  66. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +7 -7
  67. package/src/runtime/routes/index.ts +0 -4
  68. package/src/runtime/routes/live-voice-routes.test.ts +4 -11
  69. package/src/runtime/routes/live-voice-routes.ts +8 -11
  70. package/src/runtime/routes/secret-routes.ts +0 -6
  71. package/src/runtime/routes/tts-routes.ts +0 -38
  72. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -7
  73. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +0 -4
  74. package/src/schedule/schedule-store.ts +0 -7
  75. package/src/schedule/scheduler.ts +0 -5
  76. package/src/schedule/worker.ts +1 -1
  77. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +2 -10
  78. package/src/stt/daemon-batch-transcriber.ts +4 -38
  79. package/src/stt/types.ts +1 -9
  80. package/src/tools/network/__tests__/web-search.test.ts +3 -250
  81. package/src/tools/network/web-search.ts +4 -165
  82. package/src/tts/__tests__/provider-resolution.test.ts +0 -5
  83. package/src/tts/provider-catalog.ts +0 -4
  84. package/src/tts/types.ts +0 -1
  85. package/src/__tests__/cloud-speech-defaults.test.ts +0 -330
  86. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1708
  87. package/src/__tests__/migration-export-http.test.ts +0 -557
  88. package/src/__tests__/migration-export-streaming.test.ts +0 -350
  89. package/src/__tests__/migration-export-to-gcs.test.ts +0 -706
  90. package/src/__tests__/migration-import-commit-http.test.ts +0 -1176
  91. package/src/__tests__/migration-import-from-gcs.test.ts +0 -534
  92. package/src/__tests__/migration-import-from-path.test.ts +0 -324
  93. package/src/__tests__/migration-import-from-url.test.ts +0 -588
  94. package/src/__tests__/migration-import-preflight-http.test.ts +0 -739
  95. package/src/__tests__/migration-jobs-status.test.ts +0 -139
  96. package/src/__tests__/migration-parity-persistence.test.ts +0 -1891
  97. package/src/__tests__/migration-transport.test.ts +0 -1089
  98. package/src/__tests__/migration-validate-http.test.ts +0 -634
  99. package/src/__tests__/migration-wizard.test.ts +0 -1395
  100. package/src/__tests__/rebind-secrets-screen.test.ts +0 -876
  101. package/src/__tests__/sanitize-config-for-transfer.test.ts +0 -259
  102. package/src/__tests__/transfer-progress-screen.test.ts +0 -1217
  103. package/src/__tests__/validation-results-screen.test.ts +0 -1130
  104. package/src/__tests__/vbundle-pax-and-symlink.test.ts +0 -147
  105. package/src/background-wake/background-wake-routes.test.ts +0 -906
  106. package/src/background-wake/cloud-client.test.ts +0 -328
  107. package/src/background-wake/cloud-client.ts +0 -196
  108. package/src/background-wake/next-wake.test.ts +0 -335
  109. package/src/background-wake/next-wake.ts +0 -188
  110. package/src/background-wake/publisher.ts +0 -93
  111. package/src/background-wake/wake-intent-hooks.test.ts +0 -234
  112. package/src/cli/commands/__tests__/backup.test.ts +0 -363
  113. package/src/cli/commands/backup.help.ts +0 -180
  114. package/src/cli/commands/backup.ts +0 -398
  115. package/src/cloud/cloud-speech.test.ts +0 -335
  116. package/src/cloud/cloud-speech.ts +0 -300
  117. package/src/config/cloud-speech-defaults.ts +0 -300
  118. package/src/config/sanitize-for-transfer.ts +0 -111
  119. package/src/live-voice/__tests__/live-voice-cloud-speech-fallback.test.ts +0 -250
  120. package/src/providers/speech-to-text/__tests__/cadet-cloud-realtime.test.ts +0 -457
  121. package/src/providers/speech-to-text/__tests__/cadet-cloud.test.ts +0 -271
  122. package/src/providers/speech-to-text/__tests__/cadet-speech-relay-connection.test.ts +0 -126
  123. package/src/providers/speech-to-text/cadet-cloud-flux-realtime.ts +0 -309
  124. package/src/providers/speech-to-text/cadet-cloud-realtime.ts +0 -315
  125. package/src/providers/speech-to-text/cadet-cloud.ts +0 -108
  126. package/src/providers/speech-to-text/cadet-speech-relay-connection.ts +0 -176
  127. package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +0 -254
  128. package/src/runtime/migrations/__tests__/job-registry.test.ts +0 -346
  129. package/src/runtime/migrations/__tests__/origin-mode.test.ts +0 -62
  130. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +0 -184
  131. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +0 -110
  132. package/src/runtime/migrations/__tests__/v1-test-helpers.ts +0 -112
  133. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +0 -285
  134. package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +0 -126
  135. package/src/runtime/migrations/__tests__/vbundle-builder-v1-shape.test.ts +0 -253
  136. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +0 -208
  137. package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +0 -485
  138. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +0 -349
  139. package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +0 -196
  140. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +0 -551
  141. package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +0 -428
  142. package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +0 -220
  143. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +0 -1823
  144. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +0 -598
  145. package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +0 -463
  146. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -538
  147. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +0 -515
  148. package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +0 -453
  149. package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +0 -319
  150. package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +0 -240
  151. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -473
  152. package/src/runtime/migrations/gcs-signed-url.ts +0 -190
  153. package/src/runtime/migrations/job-registry.ts +0 -297
  154. package/src/runtime/migrations/migration-transport.ts +0 -612
  155. package/src/runtime/migrations/migration-wizard.ts +0 -747
  156. package/src/runtime/migrations/origin-mode.ts +0 -42
  157. package/src/runtime/migrations/rebind-secrets-screen.ts +0 -412
  158. package/src/runtime/migrations/staged-import-path.ts +0 -116
  159. package/src/runtime/migrations/transfer-progress-screen.ts +0 -323
  160. package/src/runtime/migrations/validation-results-screen.ts +0 -469
  161. package/src/runtime/migrations/vbundle-builder.ts +0 -1382
  162. package/src/runtime/migrations/vbundle-import-analyzer.ts +0 -405
  163. package/src/runtime/migrations/vbundle-import-policy.ts +0 -241
  164. package/src/runtime/migrations/vbundle-importer.ts +0 -826
  165. package/src/runtime/migrations/vbundle-metadata-merge.ts +0 -113
  166. package/src/runtime/migrations/vbundle-streaming-importer.ts +0 -2844
  167. package/src/runtime/migrations/vbundle-streaming-validator.ts +0 -426
  168. package/src/runtime/migrations/vbundle-tar-stream.ts +0 -244
  169. package/src/runtime/migrations/vbundle-validator.ts +0 -724
  170. package/src/runtime/routes/__tests__/migration-cadet-metadata-reconcile.test.ts +0 -227
  171. package/src/runtime/routes/__tests__/migration-export-secrets-redacted.test.ts +0 -54
  172. package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +0 -70
  173. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +0 -257
  174. package/src/runtime/routes/background-wake-routes.ts +0 -387
  175. package/src/runtime/routes/migration-routes.ts +0 -2584
  176. package/src/tools/network/__tests__/cloud-search-proxy.test.ts +0 -282
  177. package/src/tools/network/cloud-search-proxy.ts +0 -211
  178. package/src/tts/__tests__/cadet-provider.test.ts +0 -253
  179. package/src/tts/providers/cadet-provider.ts +0 -244
  180. package/src/types/tar-stream.d.ts +0 -66
@@ -1,1891 +0,0 @@
1
- /**
2
- * Tests for macOS/iOS parity and persistence/resume behavior.
3
- *
4
- * Covers:
5
- * - Cross-platform parity: shared modules produce identical results regardless
6
- * of platform context. Serialized state is portable between macOS and iOS.
7
- * - Persistence/resume at every wizard step: serialize, deserialize, resume,
8
- * verify correct screen states.
9
- * - Interrupted flows: crash during transfer, partial completion, loading states.
10
- * - Full end-to-end flows: managed-to-self-hosted and self-hosted-to-managed
11
- * through all screen view models.
12
- * - Edge cases: timeouts during polling, stale/expired states, concurrent wizard
13
- * instances, state corruption recovery, bundle data loss after deserialization.
14
- * - Cross-screen consistency: screen transitions maintain consistent state across
15
- * validation -> transfer -> rebind screens.
16
- */
17
-
18
- import { describe, expect, test } from "bun:test";
19
-
20
- import type {
21
- ImportCommitResponse,
22
- ImportPreflightResponse,
23
- TransportConfig,
24
- ValidateResponse,
25
- } from "../runtime/migrations/migration-transport.js";
26
- import type {
27
- MigrationDirection,
28
- MigrationWizardState,
29
- StepExecutorOptions,
30
- WizardStep,
31
- } from "../runtime/migrations/migration-wizard.js";
32
- import {
33
- canRetryCurrentStep,
34
- completeRebindSecrets,
35
- createWizardState,
36
- deserializeWizardState,
37
- getCurrentStepIndex,
38
- getStepOrder,
39
- getTotalSteps,
40
- goBackTo,
41
- isResumable,
42
- isStepAccessible,
43
- isWizardComplete,
44
- prepareForResume,
45
- resetStepForRetry,
46
- selectDirection,
47
- serializeWizardState,
48
- setBundleUploaded,
49
- validateWizardTransition,
50
- } from "../runtime/migrations/migration-wizard.js";
51
- import type { RebindTaskCompletionState } from "../runtime/migrations/rebind-secrets-screen.js";
52
- import {
53
- areAllRequiredTasksComplete,
54
- completeMigration,
55
- createTaskCompletionState,
56
- deriveRebindSecretsScreenState,
57
- getTaskIds,
58
- isRebindSecretsScreenAccessible,
59
- markTaskComplete,
60
- } from "../runtime/migrations/rebind-secrets-screen.js";
61
- import {
62
- deriveTransferScreenState,
63
- executeTransferFlow,
64
- isTransferScreenAccessible,
65
- } from "../runtime/migrations/transfer-progress-screen.js";
66
- import {
67
- deriveValidationScreenState,
68
- executeValidationFlow,
69
- isValidationScreenAccessible,
70
- } from "../runtime/migrations/validation-results-screen.js";
71
-
72
- // ---------------------------------------------------------------------------
73
- // Test helpers
74
- // ---------------------------------------------------------------------------
75
-
76
- function mockFetch(
77
- status: number,
78
- body: unknown,
79
- headers?: Record<string, string>,
80
- ): typeof fetch {
81
- return (async () => {
82
- const responseHeaders = new Headers(headers);
83
- if (
84
- typeof body === "object" &&
85
- body !== undefined &&
86
- !(body instanceof ArrayBuffer)
87
- ) {
88
- responseHeaders.set("Content-Type", "application/json");
89
- return new Response(JSON.stringify(body), {
90
- status,
91
- headers: responseHeaders,
92
- });
93
- }
94
- if (body instanceof ArrayBuffer) {
95
- return new Response(body, { status, headers: responseHeaders });
96
- }
97
- return new Response(String(body), { status, headers: responseHeaders });
98
- }) as unknown as typeof fetch;
99
- }
100
-
101
- function runtimeConfig(overrides?: Partial<TransportConfig>): TransportConfig {
102
- return {
103
- baseURL: "http://localhost:7821",
104
- target: "runtime",
105
- authHeader: "Bearer test-jwt",
106
- fetchFn: mockFetch(200, {}),
107
- ...overrides,
108
- };
109
- }
110
-
111
- function managedConfig(overrides?: Partial<TransportConfig>): TransportConfig {
112
- return {
113
- baseURL: "https://cloud.mycadet.ai",
114
- target: "managed",
115
- authHeader: "session-token-abc",
116
- fetchFn: mockFetch(200, {}),
117
- ...overrides,
118
- };
119
- }
120
-
121
- function makeExecutorOptions(
122
- overrides?: Partial<StepExecutorOptions>,
123
- ): StepExecutorOptions {
124
- return {
125
- sourceConfig: runtimeConfig(),
126
- destConfig: runtimeConfig(),
127
- bundleData: new ArrayBuffer(16),
128
- ...overrides,
129
- };
130
- }
131
-
132
- function makeValidateSuccess(): ValidateResponse {
133
- return {
134
- is_valid: true,
135
- errors: [],
136
- manifest: {
137
- schema_version: 1,
138
- bundle_id: "00000000-0000-4000-8000-000000000000",
139
- created_at: "2026-03-01T00:00:00Z",
140
- assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test" },
141
- origin: { mode: "self-hosted-local" },
142
- compatibility: {
143
- min_runtime_version: "0.0.0-test",
144
- max_runtime_version: null,
145
- },
146
- contents: [
147
- { path: "config.json", sha256: "abc123", size_bytes: 1024 },
148
- { path: "skills/test.md", sha256: "def456", size_bytes: 2048 },
149
- ],
150
- checksum: "manifest-hash",
151
- secrets_redacted: false,
152
- export_options: {
153
- include_logs: false,
154
- include_browser_state: false,
155
- include_memory_vectors: false,
156
- },
157
- },
158
- };
159
- }
160
-
161
- function makePreflightSuccess(): ImportPreflightResponse {
162
- return {
163
- can_import: true,
164
- summary: {
165
- total_files: 3,
166
- files_to_create: 1,
167
- files_to_overwrite: 1,
168
- files_unchanged: 1,
169
- files_to_skip: 0,
170
- },
171
- files: [
172
- {
173
- path: "config.json",
174
- action: "overwrite",
175
- bundle_size: 1024,
176
- current_size: 800,
177
- bundle_sha256: "abc123",
178
- current_sha256: "old123",
179
- },
180
- {
181
- path: "skills/new-skill.md",
182
- action: "create",
183
- bundle_size: 512,
184
- current_size: null,
185
- bundle_sha256: "ghi789",
186
- current_sha256: null,
187
- },
188
- {
189
- path: "playbooks/default.md",
190
- action: "unchanged",
191
- bundle_size: 2048,
192
- current_size: 2048,
193
- bundle_sha256: "mno345",
194
- current_sha256: "mno345",
195
- },
196
- ],
197
- conflicts: [],
198
- manifest: {
199
- schema_version: 1,
200
- bundle_id: "00000000-0000-4000-8000-000000000000",
201
- created_at: "2026-03-01T00:00:00Z",
202
- assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test" },
203
- origin: { mode: "self-hosted-local" },
204
- compatibility: {
205
- min_runtime_version: "0.0.0-test",
206
- max_runtime_version: null,
207
- },
208
- contents: [
209
- { path: "config.json", sha256: "abc123", size_bytes: 1024 },
210
- { path: "skills/new-skill.md", sha256: "ghi789", size_bytes: 512 },
211
- ],
212
- checksum: "manifest-hash",
213
- secrets_redacted: false,
214
- export_options: {
215
- include_logs: false,
216
- include_browser_state: false,
217
- include_memory_vectors: false,
218
- },
219
- },
220
- };
221
- }
222
-
223
- function makeImportSuccess(): ImportCommitResponse {
224
- return {
225
- success: true,
226
- summary: {
227
- total_files: 3,
228
- files_created: 1,
229
- files_overwritten: 1,
230
- files_skipped: 1,
231
- backups_created: 1,
232
- },
233
- files: [
234
- {
235
- path: "config.json",
236
- disk_path: "/data/config.json",
237
- action: "overwritten",
238
- size: 1024,
239
- sha256: "abc123",
240
- backup_path: "/data/config.json.bak",
241
- },
242
- ],
243
- manifest: {
244
- schema_version: 1,
245
- bundle_id: "00000000-0000-4000-8000-000000000000",
246
- created_at: "2026-03-01T00:00:00Z",
247
- assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test" },
248
- origin: { mode: "self-hosted-local" },
249
- compatibility: {
250
- min_runtime_version: "0.0.0-test",
251
- max_runtime_version: null,
252
- },
253
- contents: [{ path: "config.json", sha256: "abc123", size_bytes: 1024 }],
254
- checksum: "manifest-hash",
255
- secrets_redacted: false,
256
- export_options: {
257
- include_logs: false,
258
- include_browser_state: false,
259
- include_memory_vectors: false,
260
- },
261
- },
262
- warnings: ["Backup created for config.json"],
263
- };
264
- }
265
-
266
- /** Advance state to a specific step for testing. */
267
- function advanceTo(
268
- step: WizardStep,
269
- direction: MigrationDirection = "managed-to-self-hosted",
270
- ): MigrationWizardState {
271
- let state = createWizardState();
272
- const stepOrder: WizardStep[] = [
273
- "select-direction",
274
- "upload-bundle",
275
- "validate",
276
- "preflight-review",
277
- "transfer",
278
- "rebind-secrets",
279
- "complete",
280
- ];
281
- const targetIdx = stepOrder.indexOf(step);
282
-
283
- if (targetIdx >= 1) {
284
- state = selectDirection(state, direction);
285
- }
286
- if (targetIdx >= 2) {
287
- state = setBundleUploaded(state);
288
- }
289
- if (targetIdx >= 3) {
290
- state = {
291
- ...state,
292
- steps: { ...state.steps, validate: { status: "success" } },
293
- validateResult: makeValidateSuccess(),
294
- currentStep: "preflight-review",
295
- };
296
- }
297
- if (targetIdx >= 4) {
298
- state = {
299
- ...state,
300
- steps: { ...state.steps, "preflight-review": { status: "success" } },
301
- preflightResult: makePreflightSuccess(),
302
- currentStep: "transfer",
303
- };
304
- }
305
- if (targetIdx >= 5) {
306
- state = {
307
- ...state,
308
- steps: { ...state.steps, transfer: { status: "success" } },
309
- importResult: makeImportSuccess(),
310
- currentStep: "rebind-secrets",
311
- };
312
- }
313
- if (targetIdx >= 6) {
314
- state = completeRebindSecrets(state);
315
- }
316
-
317
- return state;
318
- }
319
-
320
- /** Mark all required tasks as complete. */
321
- function completeOnlyRequired(
322
- state: RebindTaskCompletionState,
323
- ): RebindTaskCompletionState {
324
- let current = state;
325
- current = markTaskComplete(current, "re-enter-secrets");
326
- current = markTaskComplete(current, "rebind-channels");
327
- current = markTaskComplete(current, "reconfigure-auth");
328
- return current;
329
- }
330
-
331
- /** Mark all tasks (including optional) as complete. */
332
- function completeAllTasks(
333
- state: RebindTaskCompletionState,
334
- ): RebindTaskCompletionState {
335
- let current = state;
336
- for (const id of getTaskIds()) {
337
- current = markTaskComplete(current, id);
338
- }
339
- return current;
340
- }
341
-
342
- // ===========================================================================
343
- // 1. CROSS-PLATFORM PARITY
344
- // ===========================================================================
345
-
346
- describe("macOS + iOS parity — shared module determinism", () => {
347
- test("wizard state machine produces identical results for both directions", () => {
348
- // Both platforms use the same shared modules, so identical inputs must
349
- // produce identical outputs regardless of which platform instantiates them.
350
- const stateA = createWizardState();
351
- const stateB = createWizardState();
352
-
353
- // Compare structure (timestamps may differ slightly, so strip them)
354
- const normalize = (s: MigrationWizardState) => ({
355
- ...s,
356
- createdAt: "FIXED",
357
- updatedAt: "FIXED",
358
- });
359
-
360
- expect(normalize(stateA)).toEqual(normalize(stateB));
361
-
362
- // Advance both through the same transitions
363
- const advA = selectDirection(stateA, "managed-to-self-hosted");
364
- const advB = selectDirection(stateB, "managed-to-self-hosted");
365
-
366
- expect(normalize(advA).currentStep).toBe(normalize(advB).currentStep);
367
- expect(normalize(advA).direction).toBe(normalize(advB).direction);
368
- expect(normalize(advA).steps).toEqual(normalize(advB).steps);
369
- });
370
-
371
- test("serialized state is portable — serialize on 'macOS', deserialize on 'iOS'", () => {
372
- // Simulate creating state on one platform and deserializing on another.
373
- // The key invariant: the deserialized state must be structurally identical
374
- // (except hasBundleData which is always false after deserialization).
375
- const original = advanceTo("rebind-secrets", "managed-to-self-hosted");
376
- const json = serializeWizardState(original);
377
-
378
- // "Transfer" the JSON string to another platform
379
- const restored = deserializeWizardState(json);
380
- expect(restored).toBeDefined();
381
-
382
- // All fields except hasBundleData and timestamps should match
383
- expect(restored!.currentStep).toBe(original.currentStep);
384
- expect(restored!.direction).toBe(original.direction);
385
- expect(restored!.hasBundleData).toBe(false); // Always false after deserialization
386
-
387
- // Step states should be identical
388
- for (const step of getStepOrder(original)) {
389
- expect(restored!.steps[step].status).toBe(original.steps[step].status);
390
- }
391
-
392
- // Results should be preserved
393
- expect(restored!.validateResult).toEqual(original.validateResult);
394
- expect(restored!.preflightResult).toEqual(original.preflightResult);
395
- expect(restored!.importResult).toEqual(original.importResult);
396
- });
397
-
398
- test("state machine transitions produce identical step orders for both directions", () => {
399
- const m2s = getStepOrder(
400
- advanceTo("upload-bundle", "managed-to-self-hosted"),
401
- );
402
- const s2m = getStepOrder(
403
- advanceTo("upload-bundle", "self-hosted-to-managed"),
404
- );
405
-
406
- // Both directions use the same step sequence
407
- expect(m2s).toEqual(s2m);
408
- expect(m2s).toEqual([
409
- "select-direction",
410
- "upload-bundle",
411
- "validate",
412
- "preflight-review",
413
- "transfer",
414
- "rebind-secrets",
415
- "complete",
416
- ]);
417
- });
418
-
419
- test("validation screen view model produces identical output for same wizard state", () => {
420
- const stateM2S = advanceTo("transfer", "managed-to-self-hosted");
421
- const stateS2M = advanceTo("transfer", "self-hosted-to-managed");
422
-
423
- // Both should produce success screen states with identical structure
424
- // (only difference is the direction field on wizard state)
425
- const screenM2S = deriveValidationScreenState(stateM2S);
426
- const screenS2M = deriveValidationScreenState(stateS2M);
427
-
428
- expect(screenM2S.phase).toBe("success");
429
- expect(screenS2M.phase).toBe("success");
430
-
431
- if (screenM2S.phase === "success" && screenS2M.phase === "success") {
432
- expect(screenM2S.validation).toEqual(screenS2M.validation);
433
- expect(screenM2S.preflight).toEqual(screenS2M.preflight);
434
- }
435
- });
436
-
437
- test("transfer screen view model produces identical output for same wizard state", () => {
438
- const stateM2S = advanceTo("rebind-secrets", "managed-to-self-hosted");
439
- const stateS2M = advanceTo("rebind-secrets", "self-hosted-to-managed");
440
-
441
- const screenM2S = deriveTransferScreenState(stateM2S);
442
- const screenS2M = deriveTransferScreenState(stateS2M);
443
-
444
- expect(screenM2S.phase).toBe("success");
445
- expect(screenS2M.phase).toBe("success");
446
-
447
- if (screenM2S.phase === "success" && screenS2M.phase === "success") {
448
- expect(screenM2S.importSummary).toEqual(screenS2M.importSummary);
449
- }
450
- });
451
-
452
- test("rebind secrets screen produces identical output for same wizard state", () => {
453
- const stateM2S = advanceTo("rebind-secrets", "managed-to-self-hosted");
454
- const stateS2M = advanceTo("rebind-secrets", "self-hosted-to-managed");
455
-
456
- const completion = createTaskCompletionState();
457
- const screenM2S = deriveRebindSecretsScreenState(stateM2S, completion);
458
- const screenS2M = deriveRebindSecretsScreenState(stateS2M, completion);
459
-
460
- expect(screenM2S.phase).toBe("active");
461
- expect(screenS2M.phase).toBe("active");
462
-
463
- if (screenM2S.phase === "active" && screenS2M.phase === "active") {
464
- expect(screenM2S.tasks).toEqual(screenS2M.tasks);
465
- expect(screenM2S.requiredCount).toBe(screenS2M.requiredCount);
466
- expect(screenM2S.totalCount).toBe(screenS2M.totalCount);
467
- }
468
- });
469
-
470
- test("query helpers produce identical results for both directions", () => {
471
- const stateM2S = advanceTo("transfer", "managed-to-self-hosted");
472
- const stateS2M = advanceTo("transfer", "self-hosted-to-managed");
473
-
474
- expect(getCurrentStepIndex(stateM2S)).toBe(getCurrentStepIndex(stateS2M));
475
- expect(getTotalSteps()).toBe(7);
476
- expect(isWizardComplete(stateM2S)).toBe(false);
477
- expect(isWizardComplete(stateS2M)).toBe(false);
478
-
479
- const completeM2S = advanceTo("complete", "managed-to-self-hosted");
480
- const completeS2M = advanceTo("complete", "self-hosted-to-managed");
481
- expect(isWizardComplete(completeM2S)).toBe(true);
482
- expect(isWizardComplete(completeS2M)).toBe(true);
483
- });
484
-
485
- test("transition validation is symmetric across platform contexts", () => {
486
- const state = advanceTo("validate");
487
-
488
- // Forward transition validation
489
- const forwardResult = validateWizardTransition(state, "preflight-review");
490
- expect(forwardResult.valid).toBe(false); // validate step is idle, not success
491
-
492
- // Same call with identical state produces identical result
493
- const forwardResult2 = validateWizardTransition(state, "preflight-review");
494
- expect(forwardResult2).toEqual(forwardResult);
495
-
496
- // Back transition is always valid
497
- const backResult = validateWizardTransition(state, "upload-bundle");
498
- expect(backResult.valid).toBe(true);
499
- });
500
- });
501
-
502
- // ===========================================================================
503
- // 2. PERSISTENCE / RESUME BEHAVIOR
504
- // ===========================================================================
505
-
506
- describe("persistence/resume — serialize at each step", () => {
507
- test("persist and resume at select-direction step", () => {
508
- const state = createWizardState();
509
- const json = serializeWizardState(state);
510
- const restored = deserializeWizardState(json);
511
- expect(restored).toBeDefined();
512
- expect(restored!.currentStep).toBe("select-direction");
513
- expect(isResumable(restored!)).toBe(false); // First step, nothing to resume
514
- });
515
-
516
- test("persist and resume at upload-bundle step", () => {
517
- const state = advanceTo("upload-bundle");
518
- const json = serializeWizardState(state);
519
- const restored = deserializeWizardState(json);
520
- expect(restored).toBeDefined();
521
- expect(restored!.currentStep).toBe("upload-bundle");
522
- expect(restored!.direction).toBe("managed-to-self-hosted");
523
- expect(isResumable(restored!)).toBe(true);
524
-
525
- const resumed = prepareForResume(restored!);
526
- expect(resumed.currentStep).toBe("upload-bundle");
527
- });
528
-
529
- test("persist and resume at validate step — redirects to upload-bundle (no bundle data)", () => {
530
- const state = advanceTo("validate");
531
- const json = serializeWizardState(state);
532
- const restored = deserializeWizardState(json);
533
- expect(restored).toBeDefined();
534
- expect(restored!.hasBundleData).toBe(false);
535
-
536
- const resumed = prepareForResume(restored!);
537
- // Validate step needs bundle data, so redirect to upload-bundle
538
- expect(resumed.currentStep).toBe("upload-bundle");
539
- });
540
-
541
- test("persist and resume at preflight-review step — redirects to upload-bundle (no bundle data)", () => {
542
- const state = advanceTo("preflight-review");
543
- const json = serializeWizardState(state);
544
- const restored = deserializeWizardState(json);
545
- expect(restored).toBeDefined();
546
-
547
- const resumed = prepareForResume(restored!);
548
- expect(resumed.currentStep).toBe("upload-bundle");
549
- });
550
-
551
- test("persist and resume at transfer step — redirects to upload-bundle (no bundle data)", () => {
552
- const state = advanceTo("transfer");
553
- const json = serializeWizardState(state);
554
- const restored = deserializeWizardState(json);
555
- expect(restored).toBeDefined();
556
-
557
- const resumed = prepareForResume(restored!);
558
- expect(resumed.currentStep).toBe("upload-bundle");
559
- });
560
-
561
- test("persist and resume at rebind-secrets step — stays at rebind-secrets (no bundle needed)", () => {
562
- const state = advanceTo("rebind-secrets");
563
- const json = serializeWizardState(state);
564
- const restored = deserializeWizardState(json);
565
- expect(restored).toBeDefined();
566
- expect(isResumable(restored!)).toBe(true);
567
-
568
- const resumed = prepareForResume(restored!);
569
- expect(resumed.currentStep).toBe("rebind-secrets");
570
- });
571
-
572
- test("persist and resume at complete step — not resumable", () => {
573
- const state = advanceTo("complete");
574
- const json = serializeWizardState(state);
575
- const restored = deserializeWizardState(json);
576
- expect(restored).toBeDefined();
577
- expect(isResumable(restored!)).toBe(false);
578
- });
579
-
580
- test("persist and resume with loading status resets to idle", () => {
581
- let state = advanceTo("validate");
582
- state = {
583
- ...state,
584
- steps: { ...state.steps, validate: { status: "loading" } },
585
- };
586
-
587
- const json = serializeWizardState(state);
588
- const restored = deserializeWizardState(json);
589
- expect(restored).toBeDefined();
590
-
591
- const resumed = prepareForResume(restored!);
592
- // Loading was reset + redirected to upload-bundle because no bundle data
593
- expect(resumed.currentStep).toBe("upload-bundle");
594
- });
595
-
596
- test("persist and resume with error status preserves error details", () => {
597
- let state = advanceTo("validate");
598
- state = {
599
- ...state,
600
- steps: {
601
- ...state.steps,
602
- validate: {
603
- status: "error",
604
- error: {
605
- message: "validate: HTTP 500",
606
- code: "HTTP_500",
607
- retryable: true,
608
- },
609
- },
610
- },
611
- };
612
-
613
- const json = serializeWizardState(state);
614
- const restored = deserializeWizardState(json);
615
- expect(restored).toBeDefined();
616
- expect(restored!.steps.validate.status).toBe("error");
617
- expect(restored!.steps.validate.error?.message).toBe("validate: HTTP 500");
618
- expect(restored!.steps.validate.error?.retryable).toBe(true);
619
- });
620
-
621
- test("results survive round-trip serialization", () => {
622
- let state = advanceTo("transfer");
623
- state = {
624
- ...state,
625
- validateResult: makeValidateSuccess(),
626
- preflightResult: makePreflightSuccess(),
627
- };
628
-
629
- const json = serializeWizardState(state);
630
- const restored = deserializeWizardState(json);
631
- expect(restored).toBeDefined();
632
-
633
- // Validate result
634
- expect(restored!.validateResult).toBeDefined();
635
- expect(restored!.validateResult!.is_valid).toBe(true);
636
- if (restored!.validateResult!.is_valid) {
637
- expect(restored!.validateResult!.manifest.schema_version).toBe(1);
638
- }
639
-
640
- // Preflight result
641
- expect(restored!.preflightResult).toBeDefined();
642
- expect(restored!.preflightResult!.can_import).toBe(true);
643
- if (restored!.preflightResult!.can_import) {
644
- expect(restored!.preflightResult!.summary.total_files).toBe(3);
645
- }
646
- });
647
-
648
- test("import result survives round-trip serialization", () => {
649
- let state = advanceTo("rebind-secrets");
650
- state = {
651
- ...state,
652
- importResult: makeImportSuccess(),
653
- };
654
-
655
- const json = serializeWizardState(state);
656
- const restored = deserializeWizardState(json);
657
- expect(restored).toBeDefined();
658
-
659
- expect(restored!.importResult).toBeDefined();
660
- expect(restored!.importResult!.success).toBe(true);
661
- if (restored!.importResult!.success) {
662
- expect(restored!.importResult!.summary.total_files).toBe(3);
663
- expect(restored!.importResult!.warnings).toHaveLength(1);
664
- }
665
- });
666
- });
667
-
668
- // ===========================================================================
669
- // 3. INTERRUPTED FLOWS
670
- // ===========================================================================
671
-
672
- describe("interrupted flow recovery", () => {
673
- test("crash during transfer loading — resume resets to upload-bundle", () => {
674
- let state = advanceTo("transfer");
675
- state = {
676
- ...state,
677
- steps: { ...state.steps, transfer: { status: "loading" } },
678
- exportResult: {
679
- ok: true,
680
- filename: "export.vbundle",
681
- schemaVersion: 1,
682
- checksum: "abc",
683
- },
684
- };
685
-
686
- const json = serializeWizardState(state);
687
- const restored = deserializeWizardState(json);
688
- expect(restored).toBeDefined();
689
-
690
- const resumed = prepareForResume(restored!);
691
- // Transfer needs bundle data; since hasBundleData=false after deserialization,
692
- // redirect to upload-bundle
693
- expect(resumed.currentStep).toBe("upload-bundle");
694
- // Export result is preserved in the state even though we go back
695
- // (the user will need to re-upload the bundle)
696
- });
697
-
698
- test("crash during validate loading — resume resets to upload-bundle", () => {
699
- let state = advanceTo("validate");
700
- state = {
701
- ...state,
702
- steps: { ...state.steps, validate: { status: "loading" } },
703
- };
704
-
705
- const json = serializeWizardState(state);
706
- const restored = deserializeWizardState(json);
707
- const resumed = prepareForResume(restored!);
708
-
709
- expect(resumed.currentStep).toBe("upload-bundle");
710
- expect(resumed.steps.validate.status).toBe("idle");
711
- });
712
-
713
- test("crash during preflight-review loading — resume resets to upload-bundle", () => {
714
- let state = advanceTo("preflight-review");
715
- state = {
716
- ...state,
717
- steps: { ...state.steps, "preflight-review": { status: "loading" } },
718
- };
719
-
720
- const json = serializeWizardState(state);
721
- const restored = deserializeWizardState(json);
722
- const resumed = prepareForResume(restored!);
723
-
724
- expect(resumed.currentStep).toBe("upload-bundle");
725
- expect(resumed.steps["preflight-review"].status).toBe("idle");
726
- });
727
-
728
- test("partial completion — validate done but preflight crashed", () => {
729
- let state = advanceTo("preflight-review");
730
- // Validation succeeded, but preflight was in progress when crash occurred
731
- state = {
732
- ...state,
733
- validateResult: makeValidateSuccess(),
734
- steps: {
735
- ...state.steps,
736
- validate: { status: "success" },
737
- "preflight-review": { status: "loading" },
738
- },
739
- };
740
-
741
- const json = serializeWizardState(state);
742
- const restored = deserializeWizardState(json);
743
- const resumed = prepareForResume(restored!);
744
-
745
- // Goes to upload-bundle because no bundle data
746
- expect(resumed.currentStep).toBe("upload-bundle");
747
- // Results are cleared when rewinding to upload-bundle (no bundle data)
748
- expect(resumed.validateResult).toBeUndefined();
749
- });
750
-
751
- test("partial completion — export done but import crashed", () => {
752
- let state = advanceTo("transfer");
753
- state = {
754
- ...state,
755
- steps: { ...state.steps, transfer: { status: "loading" } },
756
- exportResult: {
757
- ok: true,
758
- filename: "export.vbundle",
759
- schemaVersion: 1,
760
- checksum: "abc",
761
- },
762
- // No importResult — import had not started or crashed
763
- };
764
-
765
- const json = serializeWizardState(state);
766
- const restored = deserializeWizardState(json);
767
- const resumed = prepareForResume(restored!);
768
-
769
- // Goes back to upload-bundle (no bundle data)
770
- expect(resumed.currentStep).toBe("upload-bundle");
771
- // Results are cleared when rewinding to upload-bundle (no bundle data)
772
- expect(resumed.exportResult).toBeUndefined();
773
- });
774
-
775
- test("error state at transfer — user can retry after resume at rebind step doesn't apply", () => {
776
- let state = advanceTo("transfer");
777
- state = {
778
- ...state,
779
- steps: {
780
- ...state.steps,
781
- transfer: {
782
- status: "error",
783
- error: {
784
- message: "import: HTTP 500",
785
- code: "HTTP_500",
786
- retryable: true,
787
- },
788
- },
789
- },
790
- };
791
-
792
- const json = serializeWizardState(state);
793
- const restored = deserializeWizardState(json);
794
- expect(restored).toBeDefined();
795
-
796
- // Without preparing for resume (raw deserialized state), error is preserved
797
- expect(restored!.steps.transfer.status).toBe("error");
798
- expect(canRetryCurrentStep(restored!)).toBe(true);
799
- });
800
- });
801
-
802
- // ===========================================================================
803
- // 4. FULL END-TO-END FLOWS
804
- // ===========================================================================
805
-
806
- describe("full end-to-end — managed-to-self-hosted migration", () => {
807
- test("complete flow through all screen view models", async () => {
808
- // Step 1: Create wizard and select direction
809
- let state = createWizardState();
810
- expect(deriveValidationScreenState(state).phase).toBe("disabled");
811
- expect(deriveTransferScreenState(state).phase).toBe("disabled");
812
- expect(
813
- deriveRebindSecretsScreenState(state, createTaskCompletionState()).phase,
814
- ).toBe("disabled");
815
-
816
- state = selectDirection(state, "managed-to-self-hosted");
817
- expect(state.currentStep).toBe("upload-bundle");
818
- expect(state.direction).toBe("managed-to-self-hosted");
819
-
820
- // Step 2: Upload bundle
821
- state = setBundleUploaded(state);
822
- expect(state.currentStep).toBe("validate");
823
- expect(state.hasBundleData).toBe(true);
824
-
825
- // Step 3: Validate + preflight
826
- const validateSuccess = makeValidateSuccess();
827
- const preflightSuccess = makePreflightSuccess();
828
-
829
- let callCount = 0;
830
- const sequentialFetch = (async () => {
831
- callCount++;
832
- if (callCount === 1) {
833
- return new Response(JSON.stringify(validateSuccess), {
834
- status: 200,
835
- headers: { "Content-Type": "application/json" },
836
- });
837
- }
838
- return new Response(JSON.stringify(preflightSuccess), {
839
- status: 200,
840
- headers: { "Content-Type": "application/json" },
841
- });
842
- }) as unknown as typeof fetch;
843
-
844
- const validateOptions = makeExecutorOptions({
845
- destConfig: runtimeConfig({ fetchFn: sequentialFetch }),
846
- });
847
-
848
- state = await executeValidationFlow(state, validateOptions);
849
- expect(state.currentStep).toBe("transfer");
850
-
851
- // Verify validation screen shows success
852
- const valScreen = deriveValidationScreenState(state);
853
- expect(valScreen.phase).toBe("success");
854
- if (valScreen.phase === "success") {
855
- expect(valScreen.validation.isValid).toBe(true);
856
- expect(valScreen.preflight.summary.totalFiles).toBe(3);
857
- }
858
-
859
- // Step 4: Transfer (managed export via async polling + runtime import)
860
- const archiveBytes = new ArrayBuffer(32);
861
- const importResponse = makeImportSuccess();
862
-
863
- let exportCallCount = 0;
864
- const sourceFetch = (async (url: string | URL | Request) => {
865
- const urlStr = String(url);
866
- if (urlStr.endsWith("/export/")) {
867
- // Managed export: initiate async job
868
- return new Response(
869
- JSON.stringify({ job_id: "exp-m2sh", status: "pending" }),
870
- {
871
- status: 200,
872
- headers: { "Content-Type": "application/json" },
873
- },
874
- );
875
- }
876
- if (urlStr.includes("/export/") && urlStr.includes("/status/")) {
877
- exportCallCount++;
878
- if (exportCallCount === 1) {
879
- // First poll: still in progress
880
- return new Response(
881
- JSON.stringify({
882
- job_id: "exp-m2sh",
883
- status: "in_progress",
884
- progress: 50,
885
- }),
886
- {
887
- status: 200,
888
- headers: { "Content-Type": "application/json" },
889
- },
890
- );
891
- }
892
- // Second poll: complete with download URL
893
- return new Response(
894
- JSON.stringify({
895
- job_id: "exp-m2sh",
896
- status: "complete",
897
- download_url: "https://cloud.mycadet.ai/downloads/exp-m2sh",
898
- }),
899
- {
900
- status: 200,
901
- headers: { "Content-Type": "application/json" },
902
- },
903
- );
904
- }
905
- if (urlStr.includes("/downloads/")) {
906
- // Download the exported archive
907
- return new Response(archiveBytes, {
908
- status: 200,
909
- headers: {
910
- "Content-Disposition": 'attachment; filename="export.vbundle"',
911
- },
912
- });
913
- }
914
- return new Response("Not found", { status: 404 });
915
- }) as unknown as typeof fetch;
916
-
917
- const destFetch = (async () => {
918
- return new Response(JSON.stringify(importResponse), {
919
- status: 200,
920
- headers: { "Content-Type": "application/json" },
921
- });
922
- }) as unknown as typeof fetch;
923
-
924
- const transferOptions = makeExecutorOptions({
925
- sourceConfig: managedConfig({ fetchFn: sourceFetch }),
926
- destConfig: runtimeConfig({ fetchFn: destFetch }),
927
- });
928
-
929
- state = await executeTransferFlow(state, transferOptions);
930
- expect(state.currentStep).toBe("rebind-secrets");
931
-
932
- // Verify transfer screen shows success
933
- const transferScreen = deriveTransferScreenState(state);
934
- expect(transferScreen.phase).toBe("success");
935
- if (transferScreen.phase === "success") {
936
- expect(transferScreen.importSummary.totalFiles).toBe(3);
937
- }
938
-
939
- // Step 5: Rebind secrets
940
- let completion = createTaskCompletionState();
941
- let rebindScreen = deriveRebindSecretsScreenState(state, completion);
942
- expect(rebindScreen.phase).toBe("active");
943
- if (rebindScreen.phase === "active") {
944
- expect(rebindScreen.allRequiredComplete).toBe(false);
945
- }
946
-
947
- // Complete required tasks one by one
948
- completion = markTaskComplete(completion, "re-enter-secrets");
949
- completion = markTaskComplete(completion, "rebind-channels");
950
- completion = markTaskComplete(completion, "reconfigure-auth");
951
-
952
- rebindScreen = deriveRebindSecretsScreenState(state, completion);
953
- if (rebindScreen.phase === "active") {
954
- expect(rebindScreen.allRequiredComplete).toBe(true);
955
- }
956
-
957
- // Step 6: Complete migration
958
- state = completeMigration(state, completion);
959
- expect(state.currentStep).toBe("complete");
960
- expect(isWizardComplete(state)).toBe(true);
961
-
962
- // All screens should still show their success states
963
- expect(deriveValidationScreenState(state).phase).toBe("success");
964
- expect(deriveTransferScreenState(state).phase).toBe("success");
965
- expect(deriveRebindSecretsScreenState(state, completion).phase).toBe(
966
- "complete",
967
- );
968
- });
969
- });
970
-
971
- describe("full end-to-end — self-hosted-to-managed migration", () => {
972
- test("complete flow with managed export (async polling)", async () => {
973
- // Step 1: Create wizard and select direction
974
- let state = createWizardState();
975
- state = selectDirection(state, "self-hosted-to-managed");
976
- expect(state.direction).toBe("self-hosted-to-managed");
977
-
978
- // Step 2: Upload bundle
979
- state = setBundleUploaded(state);
980
-
981
- // Step 3: Validate + preflight
982
- const validateSuccess = makeValidateSuccess();
983
- const preflightSuccess = makePreflightSuccess();
984
-
985
- let valCallCount = 0;
986
- const sequentialFetch = (async () => {
987
- valCallCount++;
988
- if (valCallCount === 1) {
989
- return new Response(JSON.stringify(validateSuccess), {
990
- status: 200,
991
- headers: { "Content-Type": "application/json" },
992
- });
993
- }
994
- return new Response(JSON.stringify(preflightSuccess), {
995
- status: 200,
996
- headers: { "Content-Type": "application/json" },
997
- });
998
- }) as unknown as typeof fetch;
999
-
1000
- state = await executeValidationFlow(
1001
- state,
1002
- makeExecutorOptions({
1003
- destConfig: managedConfig({ fetchFn: sequentialFetch }),
1004
- }),
1005
- );
1006
- expect(state.currentStep).toBe("transfer");
1007
-
1008
- // Step 4: Transfer with managed source (async polling)
1009
- const importResponse = makeImportSuccess();
1010
-
1011
- const sourceFetch = (async (url: string | URL | Request) => {
1012
- const urlStr = String(url);
1013
- if (urlStr.endsWith("/export")) {
1014
- // Runtime export returns binary directly
1015
- return new Response(new ArrayBuffer(32), {
1016
- status: 200,
1017
- headers: {
1018
- "Content-Disposition": 'attachment; filename="export.vbundle"',
1019
- "X-Vbundle-Schema-Version": "1",
1020
- "X-Vbundle-Manifest-Sha256": "abc",
1021
- },
1022
- });
1023
- }
1024
- return new Response("Not found", { status: 404 });
1025
- }) as unknown as typeof fetch;
1026
-
1027
- const destFetch = (async () => {
1028
- return new Response(JSON.stringify(importResponse), {
1029
- status: 200,
1030
- headers: { "Content-Type": "application/json" },
1031
- });
1032
- }) as unknown as typeof fetch;
1033
-
1034
- state = await executeTransferFlow(
1035
- state,
1036
- makeExecutorOptions({
1037
- sourceConfig: runtimeConfig({ fetchFn: sourceFetch }),
1038
- destConfig: managedConfig({ fetchFn: destFetch }),
1039
- }),
1040
- );
1041
- expect(state.currentStep).toBe("rebind-secrets");
1042
-
1043
- // Step 5-6: Rebind and complete
1044
- const completion = completeOnlyRequired(createTaskCompletionState());
1045
- state = completeMigration(state, completion);
1046
- expect(isWizardComplete(state)).toBe(true);
1047
- });
1048
- });
1049
-
1050
- // ===========================================================================
1051
- // 5. EDGE CASES
1052
- // ===========================================================================
1053
-
1054
- describe("edge cases — import failures and transport errors", () => {
1055
- test("transfer step handles import validation failure as retryable error", async () => {
1056
- const destFetch = (async () => {
1057
- return new Response(
1058
- JSON.stringify({
1059
- success: false,
1060
- reason: "validation_failed",
1061
- message: "Manifest SHA mismatch",
1062
- }),
1063
- {
1064
- status: 200,
1065
- headers: { "Content-Type": "application/json" },
1066
- },
1067
- );
1068
- }) as unknown as typeof fetch;
1069
-
1070
- const state = advanceTo("transfer");
1071
- const options = makeExecutorOptions({
1072
- destConfig: runtimeConfig({ fetchFn: destFetch }),
1073
- });
1074
-
1075
- const result = await executeTransferFlow(state, options);
1076
- expect(result.steps.transfer.status).toBe("error");
1077
- expect(result.steps.transfer.error?.message).toContain(
1078
- "Manifest SHA mismatch",
1079
- );
1080
- expect(result.steps.transfer.error?.code).toBe("validation_failed");
1081
-
1082
- const screen = deriveTransferScreenState(result);
1083
- expect(screen.phase).toBe("error");
1084
- if (screen.phase === "error") {
1085
- expect(screen.failedPhase).toBe("import");
1086
- expect(screen.canRetry).toBe(true);
1087
- }
1088
- });
1089
-
1090
- test("transfer step handles import write failure", async () => {
1091
- const destFetch = (async () => {
1092
- return new Response(
1093
- JSON.stringify({
1094
- success: false,
1095
- reason: "write_failed",
1096
- message: "Disk full",
1097
- }),
1098
- {
1099
- status: 200,
1100
- headers: { "Content-Type": "application/json" },
1101
- },
1102
- );
1103
- }) as unknown as typeof fetch;
1104
-
1105
- const state = advanceTo("transfer");
1106
- const options = makeExecutorOptions({
1107
- destConfig: runtimeConfig({ fetchFn: destFetch }),
1108
- });
1109
-
1110
- const result = await executeTransferFlow(state, options);
1111
- expect(result.steps.transfer.status).toBe("error");
1112
- expect(result.steps.transfer.error?.code).toBe("write_failed");
1113
- expect(result.steps.transfer.error?.retryable).toBe(true);
1114
- });
1115
-
1116
- test("transfer step handles import extraction failure", async () => {
1117
- const destFetch = (async () => {
1118
- return new Response(
1119
- JSON.stringify({
1120
- success: false,
1121
- reason: "extraction_failed",
1122
- message: "Corrupt archive",
1123
- }),
1124
- {
1125
- status: 200,
1126
- headers: { "Content-Type": "application/json" },
1127
- },
1128
- );
1129
- }) as unknown as typeof fetch;
1130
-
1131
- const state = advanceTo("transfer");
1132
- const options = makeExecutorOptions({
1133
- destConfig: runtimeConfig({ fetchFn: destFetch }),
1134
- });
1135
-
1136
- const result = await executeTransferFlow(state, options);
1137
- expect(result.steps.transfer.status).toBe("error");
1138
- expect(result.steps.transfer.error?.message).toContain("Corrupt archive");
1139
- });
1140
-
1141
- test("transfer step handles destination HTTP 503 as retryable transport error", async () => {
1142
- const state = advanceTo("transfer");
1143
- const options = makeExecutorOptions({
1144
- destConfig: runtimeConfig({
1145
- fetchFn: mockFetch(503, "Service Unavailable"),
1146
- }),
1147
- });
1148
-
1149
- const result = await executeTransferFlow(state, options);
1150
- expect(result.steps.transfer.status).toBe("error");
1151
-
1152
- const screen = deriveTransferScreenState(result);
1153
- expect(screen.phase).toBe("error");
1154
- if (screen.phase === "error") {
1155
- expect(screen.canRetry).toBe(true);
1156
- expect(screen.failedPhase).toBe("import");
1157
- }
1158
- });
1159
-
1160
- test("transfer step handles destination HTTP 429 rate limit as retryable", async () => {
1161
- const archiveBytes = new ArrayBuffer(32);
1162
- const sourceFetch = (async () => {
1163
- return new Response(archiveBytes, {
1164
- status: 200,
1165
- headers: {
1166
- "Content-Disposition": 'attachment; filename="export.vbundle"',
1167
- "X-Vbundle-Schema-Version": "1",
1168
- "X-Vbundle-Manifest-Sha256": "abc",
1169
- },
1170
- });
1171
- }) as unknown as typeof fetch;
1172
-
1173
- const state = advanceTo("transfer");
1174
- const options = makeExecutorOptions({
1175
- sourceConfig: runtimeConfig({ fetchFn: sourceFetch }),
1176
- destConfig: runtimeConfig({
1177
- fetchFn: mockFetch(429, "Too Many Requests"),
1178
- }),
1179
- });
1180
-
1181
- const result = await executeTransferFlow(state, options);
1182
- expect(result.steps.transfer.status).toBe("error");
1183
- expect(result.steps.transfer.error?.retryable).toBe(true);
1184
- expect(result.steps.transfer.error?.code).toBe("HTTP_429");
1185
- });
1186
- });
1187
-
1188
- describe("edge cases — stale/expired states", () => {
1189
- test("very old serialized state deserializes correctly", () => {
1190
- // Simulate a state serialized months ago
1191
- const oldState: MigrationWizardState = {
1192
- currentStep: "rebind-secrets",
1193
- direction: "managed-to-self-hosted",
1194
- steps: {
1195
- "select-direction": { status: "success" },
1196
- "upload-bundle": { status: "success" },
1197
- validate: { status: "success" },
1198
- "preflight-review": { status: "success" },
1199
- transfer: { status: "success" },
1200
- "rebind-secrets": { status: "idle" },
1201
- complete: { status: "idle" },
1202
- },
1203
- hasBundleData: true,
1204
- createdAt: "2024-01-01T00:00:00Z", // Over a year old
1205
- updatedAt: "2024-01-01T01:00:00Z",
1206
- };
1207
-
1208
- const json = JSON.stringify(oldState);
1209
- const restored = deserializeWizardState(json);
1210
- expect(restored).toBeDefined();
1211
- expect(restored!.currentStep).toBe("rebind-secrets");
1212
- expect(restored!.hasBundleData).toBe(false); // Always false after deserialization
1213
-
1214
- // Resume should work even for stale state
1215
- expect(isResumable(restored!)).toBe(true);
1216
- const resumed = prepareForResume(restored!);
1217
- expect(resumed.currentStep).toBe("rebind-secrets");
1218
- });
1219
-
1220
- test("state with expired timestamps still functions correctly", () => {
1221
- const state = advanceTo("transfer");
1222
- // Manually set old timestamps
1223
- const oldState = {
1224
- ...state,
1225
- createdAt: "2023-06-15T00:00:00Z",
1226
- updatedAt: "2023-06-15T01:00:00Z",
1227
- };
1228
-
1229
- const json = serializeWizardState(oldState);
1230
- const restored = deserializeWizardState(json);
1231
- expect(restored).toBeDefined();
1232
- expect(restored!.createdAt).toBe("2023-06-15T00:00:00Z");
1233
-
1234
- // The wizard should still work — transfer step is import-only
1235
- const screen = deriveTransferScreenState(restored!);
1236
- expect(screen.phase).toBe("importing");
1237
- });
1238
- });
1239
-
1240
- describe("edge cases — concurrent wizard instances", () => {
1241
- test("two independent wizard instances maintain separate state", () => {
1242
- // Advance two wizards to different steps
1243
- const w1Advanced = advanceTo("transfer", "managed-to-self-hosted");
1244
- const w2Advanced = advanceTo("upload-bundle", "self-hosted-to-managed");
1245
-
1246
- // States are completely independent
1247
- expect(w1Advanced.currentStep).toBe("transfer");
1248
- expect(w2Advanced.currentStep).toBe("upload-bundle");
1249
- expect(w1Advanced.direction).toBe("managed-to-self-hosted");
1250
- expect(w2Advanced.direction).toBe("self-hosted-to-managed");
1251
-
1252
- // Serializing both produces independent JSON
1253
- const json1 = serializeWizardState(w1Advanced);
1254
- const json2 = serializeWizardState(w2Advanced);
1255
- expect(json1).not.toBe(json2);
1256
-
1257
- // Deserializing restores independent state
1258
- const restored1 = deserializeWizardState(json1);
1259
- const restored2 = deserializeWizardState(json2);
1260
- expect(restored1!.currentStep).toBe("transfer");
1261
- expect(restored2!.currentStep).toBe("upload-bundle");
1262
- });
1263
-
1264
- test("modifying one wizard does not affect a serialized snapshot of another", () => {
1265
- let wizard = advanceTo("rebind-secrets");
1266
- const snapshot = serializeWizardState(wizard);
1267
-
1268
- // Advance the wizard to complete
1269
- wizard = completeRebindSecrets(wizard);
1270
- expect(wizard.currentStep).toBe("complete");
1271
-
1272
- // The snapshot should still restore to rebind-secrets
1273
- const restored = deserializeWizardState(snapshot);
1274
- expect(restored!.currentStep).toBe("rebind-secrets");
1275
- expect(isWizardComplete(restored!)).toBe(false);
1276
- });
1277
- });
1278
-
1279
- describe("edge cases — state corruption recovery", () => {
1280
- test("empty string returns undefined", () => {
1281
- expect(deserializeWizardState("")).toBeUndefined();
1282
- });
1283
-
1284
- test("null returns undefined", () => {
1285
- expect(deserializeWizardState("null")).toBeUndefined();
1286
- });
1287
-
1288
- test("invalid JSON returns undefined", () => {
1289
- expect(deserializeWizardState("{invalid json}")).toBeUndefined();
1290
- });
1291
-
1292
- test("valid JSON but missing required fields returns undefined", () => {
1293
- expect(deserializeWizardState('{"foo": "bar"}')).toBeUndefined();
1294
- });
1295
-
1296
- test("missing currentStep returns undefined", () => {
1297
- const partial = {
1298
- steps: {},
1299
- createdAt: "2025-01-01T00:00:00Z",
1300
- updatedAt: "2025-01-01T00:00:00Z",
1301
- };
1302
- expect(deserializeWizardState(JSON.stringify(partial))).toBeUndefined();
1303
- });
1304
-
1305
- test("unknown currentStep value returns undefined", () => {
1306
- const invalid: Record<string, unknown> = {
1307
- currentStep: "nonexistent-step",
1308
- steps: {
1309
- "select-direction": { status: "idle" },
1310
- "upload-bundle": { status: "idle" },
1311
- validate: { status: "idle" },
1312
- "preflight-review": { status: "idle" },
1313
- transfer: { status: "idle" },
1314
- "rebind-secrets": { status: "idle" },
1315
- complete: { status: "idle" },
1316
- },
1317
- hasBundleData: false,
1318
- createdAt: "2025-01-01T00:00:00Z",
1319
- updatedAt: "2025-01-01T00:00:00Z",
1320
- };
1321
- expect(deserializeWizardState(JSON.stringify(invalid))).toBeUndefined();
1322
- });
1323
-
1324
- test("missing step entries returns undefined", () => {
1325
- const incomplete = {
1326
- currentStep: "validate",
1327
- steps: {
1328
- "select-direction": { status: "success" },
1329
- // Missing other steps
1330
- },
1331
- createdAt: "2025-01-01T00:00:00Z",
1332
- updatedAt: "2025-01-01T00:00:00Z",
1333
- };
1334
- expect(deserializeWizardState(JSON.stringify(incomplete))).toBeUndefined();
1335
- });
1336
-
1337
- test("step with missing status returns undefined", () => {
1338
- const badStatus = {
1339
- currentStep: "validate",
1340
- steps: {
1341
- "select-direction": { status: "success" },
1342
- "upload-bundle": { status: "success" },
1343
- validate: {}, // Missing status
1344
- "preflight-review": { status: "idle" },
1345
- transfer: { status: "idle" },
1346
- "rebind-secrets": { status: "idle" },
1347
- complete: { status: "idle" },
1348
- },
1349
- createdAt: "2025-01-01T00:00:00Z",
1350
- updatedAt: "2025-01-01T00:00:00Z",
1351
- };
1352
- expect(deserializeWizardState(JSON.stringify(badStatus))).toBeUndefined();
1353
- });
1354
-
1355
- test("corrupted JSON with partial data is safely rejected", () => {
1356
- const truncated = '{"currentStep":"validate","steps":{"select-direction":{';
1357
- expect(deserializeWizardState(truncated)).toBeUndefined();
1358
- });
1359
-
1360
- test("array instead of object returns undefined", () => {
1361
- expect(deserializeWizardState("[1, 2, 3]")).toBeUndefined();
1362
- });
1363
-
1364
- test("number instead of object returns undefined", () => {
1365
- expect(deserializeWizardState("42")).toBeUndefined();
1366
- });
1367
- });
1368
-
1369
- describe("edge cases — bundle data loss after deserialization", () => {
1370
- test("hasBundleData is always false after deserialization", () => {
1371
- const state = advanceTo("validate");
1372
- expect(state.hasBundleData).toBe(true);
1373
-
1374
- const json = serializeWizardState(state);
1375
- const restored = deserializeWizardState(json);
1376
- expect(restored!.hasBundleData).toBe(false);
1377
- });
1378
-
1379
- test("all bundle-requiring steps redirect to upload-bundle on resume without bundle data", () => {
1380
- const bundleRequiringSteps: WizardStep[] = [
1381
- "validate",
1382
- "preflight-review",
1383
- "transfer",
1384
- ];
1385
-
1386
- for (const step of bundleRequiringSteps) {
1387
- const state = advanceTo(step);
1388
- const json = serializeWizardState(state);
1389
- const restored = deserializeWizardState(json);
1390
- const resumed = prepareForResume(restored!);
1391
-
1392
- expect(resumed.currentStep).toBe("upload-bundle");
1393
- }
1394
- });
1395
-
1396
- test("non-bundle-requiring steps stay in place on resume", () => {
1397
- // rebind-secrets does not need bundle data
1398
- const state = advanceTo("rebind-secrets");
1399
- const json = serializeWizardState(state);
1400
- const restored = deserializeWizardState(json);
1401
- const resumed = prepareForResume(restored!);
1402
-
1403
- expect(resumed.currentStep).toBe("rebind-secrets");
1404
- });
1405
-
1406
- test("direction survives but step results are cleared when bundle data is lost", () => {
1407
- let state = advanceTo("transfer");
1408
- state = {
1409
- ...state,
1410
- validateResult: makeValidateSuccess(),
1411
- preflightResult: makePreflightSuccess(),
1412
- };
1413
-
1414
- const json = serializeWizardState(state);
1415
- const restored = deserializeWizardState(json);
1416
- const resumed = prepareForResume(restored!);
1417
-
1418
- // Redirected to upload-bundle; direction is preserved but results are cleared
1419
- expect(resumed.currentStep).toBe("upload-bundle");
1420
- expect(resumed.direction).toBe("managed-to-self-hosted");
1421
- expect(resumed.validateResult).toBeUndefined();
1422
- expect(resumed.preflightResult).toBeUndefined();
1423
- });
1424
- });
1425
-
1426
- // ===========================================================================
1427
- // 6. CROSS-SCREEN CONSISTENCY
1428
- // ===========================================================================
1429
-
1430
- describe("cross-screen consistency — validation -> transfer -> rebind", () => {
1431
- test("all screens report disabled before direction is selected", () => {
1432
- const state = createWizardState();
1433
- const completion = createTaskCompletionState();
1434
-
1435
- expect(deriveValidationScreenState(state).phase).toBe("disabled");
1436
- expect(deriveTransferScreenState(state).phase).toBe("disabled");
1437
- expect(deriveRebindSecretsScreenState(state, completion).phase).toBe(
1438
- "disabled",
1439
- );
1440
- });
1441
-
1442
- test("only validation screen is accessible at validate step", () => {
1443
- const state = advanceTo("validate");
1444
-
1445
- expect(isValidationScreenAccessible(state)).toBe(true);
1446
- expect(isTransferScreenAccessible(state)).toBe(false);
1447
- expect(isRebindSecretsScreenAccessible(state)).toBe(false);
1448
- });
1449
-
1450
- test("validation and transfer screens accessible at transfer step", () => {
1451
- const state = advanceTo("transfer");
1452
-
1453
- expect(isValidationScreenAccessible(state)).toBe(true);
1454
- expect(isTransferScreenAccessible(state)).toBe(true);
1455
- expect(isRebindSecretsScreenAccessible(state)).toBe(false);
1456
- });
1457
-
1458
- test("all screens accessible at rebind-secrets step", () => {
1459
- const state = advanceTo("rebind-secrets");
1460
-
1461
- // Validation screen shows success from earlier steps
1462
- expect(deriveValidationScreenState(state).phase).toBe("success");
1463
- // Transfer screen shows success
1464
- expect(deriveTransferScreenState(state).phase).toBe("success");
1465
- // Rebind screen is active
1466
- expect(isRebindSecretsScreenAccessible(state)).toBe(true);
1467
- });
1468
-
1469
- test("all screens show their success states at complete step", () => {
1470
- const state = advanceTo("complete");
1471
- const completion = completeAllTasks(createTaskCompletionState());
1472
-
1473
- expect(deriveValidationScreenState(state).phase).toBe("success");
1474
- expect(deriveTransferScreenState(state).phase).toBe("success");
1475
- expect(deriveRebindSecretsScreenState(state, completion).phase).toBe(
1476
- "complete",
1477
- );
1478
- });
1479
-
1480
- test("going back from transfer resets transfer but preserves validation", () => {
1481
- let state = advanceTo("transfer");
1482
- state = {
1483
- ...state,
1484
- validateResult: makeValidateSuccess(),
1485
- preflightResult: makePreflightSuccess(),
1486
- };
1487
-
1488
- // Go back to preflight-review
1489
- const backState = goBackTo(state, "preflight-review");
1490
-
1491
- // Validation screen should still show results
1492
- const valScreen = deriveValidationScreenState(backState);
1493
- expect(valScreen.phase).toBe("loading"); // preflight idle means "preparing"
1494
-
1495
- // Transfer screen should be disabled
1496
- expect(deriveTransferScreenState(backState).phase).toBe("disabled");
1497
- });
1498
-
1499
- test("going back from rebind-secrets resets rebind and transfer", () => {
1500
- let state = advanceTo("rebind-secrets");
1501
- state = {
1502
- ...state,
1503
- validateResult: makeValidateSuccess(),
1504
- preflightResult: makePreflightSuccess(),
1505
- importResult: makeImportSuccess(),
1506
- };
1507
-
1508
- // Go back to transfer
1509
- const backState = goBackTo(state, "transfer");
1510
-
1511
- expect(backState.currentStep).toBe("transfer");
1512
- expect(backState.steps["rebind-secrets"].status).toBe("idle");
1513
- expect(backState.steps.transfer.status).toBe("idle");
1514
-
1515
- // Validation results should be preserved
1516
- expect(backState.validateResult).toBeDefined();
1517
- expect(backState.preflightResult).toBeDefined();
1518
- // Transfer results should be cleared
1519
- expect(backState.exportResult).toBeUndefined();
1520
- expect(backState.importResult).toBeUndefined();
1521
- });
1522
-
1523
- test("screen states are consistent through full serialize/resume cycle at each step", () => {
1524
- const steps: WizardStep[] = [
1525
- "select-direction",
1526
- "upload-bundle",
1527
- "validate",
1528
- "preflight-review",
1529
- "transfer",
1530
- "rebind-secrets",
1531
- "complete",
1532
- ];
1533
-
1534
- for (const step of steps) {
1535
- const state = advanceTo(step);
1536
- const json = serializeWizardState(state);
1537
- const restored = deserializeWizardState(json);
1538
- expect(restored).toBeDefined();
1539
-
1540
- // Screen derivation should not throw for any step
1541
- const valScreen = deriveValidationScreenState(restored!);
1542
- const transferScreen = deriveTransferScreenState(restored!);
1543
- const rebindScreen = deriveRebindSecretsScreenState(
1544
- restored!,
1545
- createTaskCompletionState(),
1546
- );
1547
-
1548
- // All screens should return a valid phase
1549
- expect(valScreen.phase).toBeDefined();
1550
- expect(transferScreen.phase).toBeDefined();
1551
- expect(rebindScreen.phase).toBeDefined();
1552
- }
1553
- });
1554
-
1555
- test("validation screen success data is consistent with preflight results", () => {
1556
- const state = advanceTo("transfer");
1557
- const screen = deriveValidationScreenState(state);
1558
-
1559
- if (screen.phase === "success") {
1560
- const preflightResult = state.preflightResult;
1561
- expect(preflightResult).toBeDefined();
1562
-
1563
- if (preflightResult && preflightResult.can_import) {
1564
- // Screen summary should match raw preflight data
1565
- expect(screen.preflight.summary.totalFiles).toBe(
1566
- preflightResult.summary.total_files,
1567
- );
1568
- expect(screen.preflight.summary.filesToCreate).toBe(
1569
- preflightResult.summary.files_to_create,
1570
- );
1571
- expect(screen.preflight.summary.filesToOverwrite).toBe(
1572
- preflightResult.summary.files_to_overwrite,
1573
- );
1574
- expect(screen.preflight.summary.filesUnchanged).toBe(
1575
- preflightResult.summary.files_unchanged,
1576
- );
1577
-
1578
- // File count should match
1579
- expect(screen.preflight.files.length).toBe(
1580
- preflightResult.files.length,
1581
- );
1582
- }
1583
- }
1584
- });
1585
-
1586
- test("transfer screen import summary is consistent with import result", () => {
1587
- const state = advanceTo("rebind-secrets");
1588
- const screen = deriveTransferScreenState(state);
1589
-
1590
- if (screen.phase === "success") {
1591
- const importResult = state.importResult;
1592
- expect(importResult).toBeDefined();
1593
-
1594
- if (importResult && importResult.success) {
1595
- expect(screen.importSummary.totalFiles).toBe(
1596
- importResult.summary.total_files,
1597
- );
1598
- expect(screen.importSummary.filesCreated).toBe(
1599
- importResult.summary.files_created,
1600
- );
1601
- expect(screen.importSummary.filesOverwritten).toBe(
1602
- importResult.summary.files_overwritten,
1603
- );
1604
- expect(screen.importSummary.filesSkipped).toBe(
1605
- importResult.summary.files_skipped,
1606
- );
1607
- expect(screen.importSummary.backupsCreated).toBe(
1608
- importResult.summary.backups_created,
1609
- );
1610
- }
1611
- }
1612
- });
1613
-
1614
- test("rebind task count stays consistent throughout the wizard lifecycle", () => {
1615
- const taskIds = getTaskIds();
1616
- const expectedTaskCount = 4;
1617
-
1618
- expect(taskIds.length).toBe(expectedTaskCount);
1619
-
1620
- // At every step, if the screen is active, it should show the same tasks
1621
- const state = advanceTo("rebind-secrets");
1622
- const completion = createTaskCompletionState();
1623
- const screen = deriveRebindSecretsScreenState(state, completion);
1624
-
1625
- if (screen.phase === "active") {
1626
- expect(screen.totalCount).toBe(expectedTaskCount);
1627
- expect(screen.tasks.length).toBe(expectedTaskCount);
1628
- // Task IDs match
1629
- const screenTaskIds = screen.tasks.map((t) => t.id);
1630
- expect(screenTaskIds).toEqual([...taskIds]);
1631
- }
1632
- });
1633
- });
1634
-
1635
- describe("cross-screen consistency — error propagation", () => {
1636
- test("validation error does not leak into transfer or rebind screens", () => {
1637
- let state = advanceTo("validate");
1638
- state = {
1639
- ...state,
1640
- steps: {
1641
- ...state.steps,
1642
- validate: {
1643
- status: "error",
1644
- error: {
1645
- message: "validate: HTTP 500",
1646
- code: "HTTP_500",
1647
- retryable: true,
1648
- },
1649
- },
1650
- },
1651
- };
1652
-
1653
- // Validation screen shows the error
1654
- const valScreen = deriveValidationScreenState(state);
1655
- expect(valScreen.phase).toBe("transport-error");
1656
-
1657
- // Transfer and rebind screens should be disabled, not errored
1658
- expect(deriveTransferScreenState(state).phase).toBe("disabled");
1659
- expect(
1660
- deriveRebindSecretsScreenState(state, createTaskCompletionState()).phase,
1661
- ).toBe("disabled");
1662
- });
1663
-
1664
- test("transfer error does not leak into validation or rebind screens", () => {
1665
- let state = advanceTo("transfer");
1666
- state = {
1667
- ...state,
1668
- steps: {
1669
- ...state.steps,
1670
- transfer: {
1671
- status: "error",
1672
- error: {
1673
- message: "export: HTTP 500",
1674
- code: "HTTP_500",
1675
- retryable: true,
1676
- },
1677
- },
1678
- },
1679
- };
1680
-
1681
- // Transfer screen shows the error
1682
- const transferScreen = deriveTransferScreenState(state);
1683
- expect(transferScreen.phase).toBe("error");
1684
-
1685
- // Validation screen should still show success (validate and preflight passed)
1686
- const valScreen = deriveValidationScreenState(state);
1687
- expect(valScreen.phase).toBe("success");
1688
-
1689
- // Rebind screen should be disabled
1690
- expect(
1691
- deriveRebindSecretsScreenState(state, createTaskCompletionState()).phase,
1692
- ).toBe("disabled");
1693
- });
1694
- });
1695
-
1696
- describe("cross-screen consistency — step accessibility invariants", () => {
1697
- test("step accessibility is monotonically increasing through normal flow", () => {
1698
- const steps: WizardStep[] = [
1699
- "select-direction",
1700
- "upload-bundle",
1701
- "validate",
1702
- "preflight-review",
1703
- "transfer",
1704
- "rebind-secrets",
1705
- ];
1706
-
1707
- // At each step, all previous steps should be accessible (can go back)
1708
- for (let i = 0; i < steps.length; i++) {
1709
- const state = advanceTo(steps[i]);
1710
- for (let j = 0; j < i; j++) {
1711
- expect(isStepAccessible(state, steps[j])).toBe(true);
1712
- }
1713
- }
1714
- });
1715
-
1716
- test("complete step is terminal — no transitions allowed", () => {
1717
- const state = advanceTo("complete");
1718
- const steps: WizardStep[] = [
1719
- "select-direction",
1720
- "upload-bundle",
1721
- "validate",
1722
- "preflight-review",
1723
- "transfer",
1724
- "rebind-secrets",
1725
- ];
1726
-
1727
- for (const step of steps) {
1728
- const result = validateWizardTransition(state, step);
1729
- expect(result.valid).toBe(false);
1730
- expect(result.reason).toContain("terminal");
1731
- }
1732
- });
1733
- });
1734
-
1735
- // ===========================================================================
1736
- // 7. ADDITIONAL PERSISTENCE EDGE CASES
1737
- // ===========================================================================
1738
-
1739
- describe("persistence — task completion state portability", () => {
1740
- test("task completion state serializes and deserializes independently", () => {
1741
- let completion = createTaskCompletionState();
1742
- completion = markTaskComplete(completion, "re-enter-secrets");
1743
- completion = markTaskComplete(completion, "rebind-channels");
1744
-
1745
- const json = JSON.stringify(completion);
1746
- const restored = JSON.parse(json) as RebindTaskCompletionState;
1747
-
1748
- expect(restored["re-enter-secrets"]).toBe(true);
1749
- expect(restored["rebind-channels"]).toBe(true);
1750
- expect(restored["reconfigure-auth"]).toBe(false);
1751
- expect(restored["verify-webhooks"]).toBe(false);
1752
-
1753
- // areAllRequiredTasksComplete should work on deserialized state
1754
- expect(areAllRequiredTasksComplete(restored)).toBe(false);
1755
- });
1756
-
1757
- test("wizard state + task completion state can be stored and restored together", () => {
1758
- const wizardState = advanceTo("rebind-secrets");
1759
- let completion = createTaskCompletionState();
1760
- completion = completeOnlyRequired(completion);
1761
-
1762
- // Simulate persisting both as a combined payload
1763
- const combined = JSON.stringify({
1764
- wizard: JSON.parse(serializeWizardState(wizardState)),
1765
- tasks: completion,
1766
- });
1767
-
1768
- const parsed = JSON.parse(combined);
1769
- const restoredWizard = deserializeWizardState(
1770
- JSON.stringify(parsed.wizard),
1771
- );
1772
- const restoredTasks = parsed.tasks as RebindTaskCompletionState;
1773
-
1774
- expect(restoredWizard).toBeDefined();
1775
- expect(restoredWizard!.currentStep).toBe("rebind-secrets");
1776
- expect(areAllRequiredTasksComplete(restoredTasks)).toBe(true);
1777
-
1778
- // Derive screen from restored state
1779
- const screen = deriveRebindSecretsScreenState(
1780
- restoredWizard!,
1781
- restoredTasks,
1782
- );
1783
- expect(screen.phase).toBe("active");
1784
- if (screen.phase === "active") {
1785
- expect(screen.allRequiredComplete).toBe(true);
1786
- }
1787
- });
1788
- });
1789
-
1790
- describe("persistence — double serialization idempotency", () => {
1791
- test("serializing twice produces identical output", () => {
1792
- const state = advanceTo("transfer");
1793
- const json1 = serializeWizardState(state);
1794
- const restored = deserializeWizardState(json1);
1795
- const json2 = serializeWizardState(restored!);
1796
-
1797
- // hasBundleData changes from true to false on deserialization,
1798
- // so the second serialization will differ from the first in that field.
1799
- const parsed1 = JSON.parse(json1);
1800
- const parsed2 = JSON.parse(json2);
1801
- parsed1.hasBundleData = false; // Normalize for comparison
1802
- expect(parsed2).toEqual(parsed1);
1803
- });
1804
-
1805
- test("prepareForResume is idempotent for non-loading, non-bundle steps", () => {
1806
- const state = advanceTo("rebind-secrets");
1807
- const json = serializeWizardState(state);
1808
- const restored = deserializeWizardState(json)!;
1809
-
1810
- const resumed1 = prepareForResume(restored);
1811
- const resumed2 = prepareForResume(resumed1);
1812
-
1813
- // Both should be at rebind-secrets with idle status
1814
- expect(resumed1.currentStep).toBe(resumed2.currentStep);
1815
- expect(resumed1.steps["rebind-secrets"].status).toBe(
1816
- resumed2.steps["rebind-secrets"].status,
1817
- );
1818
- });
1819
- });
1820
-
1821
- // ===========================================================================
1822
- // 8. RETRY + BACK NAVIGATION AFTER PERSISTENCE
1823
- // ===========================================================================
1824
-
1825
- describe("retry and back navigation after persistence round-trip", () => {
1826
- test("can retry after restoring error state from persistence", () => {
1827
- let state = advanceTo("validate");
1828
- state = {
1829
- ...state,
1830
- steps: {
1831
- ...state.steps,
1832
- validate: {
1833
- status: "error",
1834
- error: {
1835
- message: "validate: HTTP 500",
1836
- code: "HTTP_500",
1837
- retryable: true,
1838
- },
1839
- },
1840
- },
1841
- };
1842
-
1843
- const json = serializeWizardState(state);
1844
- const restored = deserializeWizardState(json)!;
1845
-
1846
- // Should be able to retry from restored state
1847
- expect(canRetryCurrentStep(restored)).toBe(true);
1848
-
1849
- const reset = resetStepForRetry(restored);
1850
- expect(reset.steps.validate.status).toBe("idle");
1851
- expect(reset.currentStep).toBe("validate");
1852
- });
1853
-
1854
- test("can navigate back after restoring state from persistence", () => {
1855
- const state = advanceTo("transfer");
1856
- const json = serializeWizardState(state);
1857
- const restored = deserializeWizardState(json)!;
1858
-
1859
- // Should be able to go back
1860
- const backState = goBackTo(restored, "validate");
1861
- expect(backState.currentStep).toBe("validate");
1862
- expect(backState.steps.validate.status).toBe("idle");
1863
- expect(backState.steps["preflight-review"].status).toBe("idle");
1864
- expect(backState.steps.transfer.status).toBe("idle");
1865
- });
1866
-
1867
- test("non-retryable error is preserved through persistence round-trip", () => {
1868
- let state = advanceTo("validate");
1869
- state = {
1870
- ...state,
1871
- steps: {
1872
- ...state.steps,
1873
- validate: {
1874
- status: "error",
1875
- error: {
1876
- message: "Fatal: corrupted bundle",
1877
- retryable: false,
1878
- },
1879
- },
1880
- },
1881
- };
1882
-
1883
- const json = serializeWizardState(state);
1884
- const restored = deserializeWizardState(json)!;
1885
-
1886
- expect(canRetryCurrentStep(restored)).toBe(false);
1887
- expect(() => resetStepForRetry(restored)).toThrow(
1888
- "not in a retryable error state",
1889
- );
1890
- });
1891
- });