@remit/web-client 0.0.61 → 0.0.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/AutoMovedIndicator.tsx +2 -1
- package/src/components/self-update/SelfUpdateOverlay.tsx +45 -0
- package/src/components/settings/AdvancedNavIcon.tsx +23 -0
- package/src/components/settings/SelfUpdatePanel.tsx +47 -0
- package/src/hooks/use-system-update.test.ts +342 -0
- package/src/hooks/use-system-update.ts +215 -0
- package/src/hooks/useAutoMovedBadge.ts +40 -9
- package/src/lib/auto-moved.test.ts +135 -49
- package/src/lib/auto-moved.ts +45 -19
- package/src/lib/self-update-state.test.ts +517 -0
- package/src/lib/self-update-state.ts +564 -0
- package/src/routes/__root.tsx +13 -8
- package/src/routes/settings/advanced.tsx +7 -4
- package/src/routes/settings.tsx +3 -9
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { beforeEach, describe, test } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
RemitImapSystemUpdateResponse,
|
|
5
|
+
RemitImapSystemUpdateRun,
|
|
6
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
7
|
+
import { ApiError } from "./api";
|
|
8
|
+
import {
|
|
9
|
+
APPLY_BUDGET_SECONDS,
|
|
10
|
+
appliesSchemaMigration,
|
|
11
|
+
clearStoredRun,
|
|
12
|
+
type DeriveInput,
|
|
13
|
+
deriveUpdateSurface,
|
|
14
|
+
type HeldRun,
|
|
15
|
+
isSurfaceAbsent,
|
|
16
|
+
loadHeldRun,
|
|
17
|
+
mapUpdatePhase,
|
|
18
|
+
NEVER_CAME_BACK_MARGIN_SECONDS,
|
|
19
|
+
releaseFromCheck,
|
|
20
|
+
SELF_UPDATE_RUN_KEY,
|
|
21
|
+
saveHeldRun,
|
|
22
|
+
} from "./self-update-state";
|
|
23
|
+
|
|
24
|
+
const NOW = Date.parse("2026-07-20T12:00:00.000Z");
|
|
25
|
+
const BUDGET_MS =
|
|
26
|
+
(APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS) * 1000;
|
|
27
|
+
|
|
28
|
+
function run(
|
|
29
|
+
overrides: Partial<RemitImapSystemUpdateRun> = {},
|
|
30
|
+
): RemitImapSystemUpdateRun {
|
|
31
|
+
return {
|
|
32
|
+
runId: "upd_1",
|
|
33
|
+
fromVersion: "0.9.3",
|
|
34
|
+
targetVersion: "0.9.4",
|
|
35
|
+
phase: "starting",
|
|
36
|
+
outcome: null,
|
|
37
|
+
startedAt: "2026-07-20T11:59:30.000Z",
|
|
38
|
+
updatedAt: "2026-07-20T11:59:45.000Z",
|
|
39
|
+
message: "Restarting Remit on 0.9.4.",
|
|
40
|
+
logCommand: "remit logs --since 10m",
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function response(
|
|
46
|
+
overrides: Partial<RemitImapSystemUpdateResponse> = {},
|
|
47
|
+
): RemitImapSystemUpdateResponse {
|
|
48
|
+
return {
|
|
49
|
+
currentVersion: "0.9.3",
|
|
50
|
+
check: { status: "ok", updateAvailable: false, ...overrides.check },
|
|
51
|
+
run: overrides.run ?? null,
|
|
52
|
+
...("currentSchemaVersion" in overrides
|
|
53
|
+
? { currentSchemaVersion: overrides.currentSchemaVersion }
|
|
54
|
+
: {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function held(overrides: Partial<HeldRun> = {}): HeldRun {
|
|
59
|
+
return {
|
|
60
|
+
runId: "upd_1",
|
|
61
|
+
attemptedVersion: "0.9.4",
|
|
62
|
+
previousVersion: "0.9.3",
|
|
63
|
+
startedAt: NOW - 10_000,
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function input(overrides: Partial<DeriveInput> = {}): DeriveInput {
|
|
69
|
+
return {
|
|
70
|
+
data: undefined,
|
|
71
|
+
isError: false,
|
|
72
|
+
error: undefined,
|
|
73
|
+
isFetching: false,
|
|
74
|
+
held: null,
|
|
75
|
+
dismissedRunId: null,
|
|
76
|
+
checkRequested: false,
|
|
77
|
+
now: NOW,
|
|
78
|
+
...overrides,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("isSurfaceAbsent", () => {
|
|
83
|
+
test("401, 403 and 404 all read as no entry point", () => {
|
|
84
|
+
for (const status of [401, 403, 404]) {
|
|
85
|
+
assert.equal(isSurfaceAbsent(new ApiError("nope", status)), true);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a 500 or a network blip is not an absent surface", () => {
|
|
90
|
+
assert.equal(isSurfaceAbsent(new ApiError("boom", 500)), false);
|
|
91
|
+
assert.equal(isSurfaceAbsent(new Error("offline")), false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("appliesSchemaMigration", () => {
|
|
96
|
+
test("true only when both versions are present and the release is higher", () => {
|
|
97
|
+
assert.equal(
|
|
98
|
+
appliesSchemaMigration(
|
|
99
|
+
response({
|
|
100
|
+
currentSchemaVersion: 4,
|
|
101
|
+
check: { status: "ok", updateAvailable: true, schemaVersion: 5 },
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
true,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("false when the release is on the same or a lower schema", () => {
|
|
109
|
+
assert.equal(
|
|
110
|
+
appliesSchemaMigration(
|
|
111
|
+
response({
|
|
112
|
+
currentSchemaVersion: 5,
|
|
113
|
+
check: { status: "ok", updateAvailable: true, schemaVersion: 5 },
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
false,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("silent when either version is absent", () => {
|
|
121
|
+
assert.equal(
|
|
122
|
+
appliesSchemaMigration(
|
|
123
|
+
response({ check: { status: "ok", schemaVersion: 5 } }),
|
|
124
|
+
),
|
|
125
|
+
false,
|
|
126
|
+
);
|
|
127
|
+
assert.equal(
|
|
128
|
+
appliesSchemaMigration(response({ currentSchemaVersion: 4 })),
|
|
129
|
+
false,
|
|
130
|
+
);
|
|
131
|
+
assert.equal(appliesSchemaMigration(undefined), false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("mapUpdatePhase", () => {
|
|
136
|
+
test("folds the nine server phases into the three the UI shows", () => {
|
|
137
|
+
assert.equal(mapUpdatePhase("pulling"), "preparing");
|
|
138
|
+
assert.equal(mapUpdatePhase("stopping"), "restarting");
|
|
139
|
+
assert.equal(mapUpdatePhase("verifying"), "reconnecting");
|
|
140
|
+
assert.equal(mapUpdatePhase("recovering"), "reconnecting");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("releaseFromCheck", () => {
|
|
145
|
+
test("builds a release when the check reports one", () => {
|
|
146
|
+
const release = releaseFromCheck(
|
|
147
|
+
response({
|
|
148
|
+
check: {
|
|
149
|
+
status: "ok",
|
|
150
|
+
updateAvailable: true,
|
|
151
|
+
latestVersion: "0.9.4",
|
|
152
|
+
publishedAt: "2026-07-14T09:00:00.000Z",
|
|
153
|
+
summary: "Faster sync.",
|
|
154
|
+
releaseNotesUrl: "https://example.test/notes",
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
NOW,
|
|
158
|
+
);
|
|
159
|
+
assert.equal(release?.version, "0.9.4");
|
|
160
|
+
assert.equal(release?.summary, "Faster sync.");
|
|
161
|
+
assert.equal(release?.releaseNotesUrl, "https://example.test/notes");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("undefined when no update is available or the check failed", () => {
|
|
165
|
+
assert.equal(releaseFromCheck(response(), NOW), undefined);
|
|
166
|
+
assert.equal(
|
|
167
|
+
releaseFromCheck(
|
|
168
|
+
response({ check: { status: "failed", error: "no route" } }),
|
|
169
|
+
NOW,
|
|
170
|
+
),
|
|
171
|
+
undefined,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("deriveUpdateSurface — the surface without a run", () => {
|
|
177
|
+
test("a 404 with no held run renders nothing anywhere", () => {
|
|
178
|
+
const result = deriveUpdateSurface(
|
|
179
|
+
input({ isError: true, error: new ApiError("off", 404) }),
|
|
180
|
+
);
|
|
181
|
+
assert.equal(result.surface.status, "absent");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("no data and no error is loading, not absent", () => {
|
|
185
|
+
const result = deriveUpdateSurface(input());
|
|
186
|
+
assert.equal(result.surface.status, "loading");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("an available update reads from the check block", () => {
|
|
190
|
+
const result = deriveUpdateSurface(
|
|
191
|
+
input({
|
|
192
|
+
data: response({
|
|
193
|
+
check: {
|
|
194
|
+
status: "ok",
|
|
195
|
+
updateAvailable: true,
|
|
196
|
+
latestVersion: "0.9.4",
|
|
197
|
+
summary: "Faster sync.",
|
|
198
|
+
},
|
|
199
|
+
}),
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
assert.equal(result.surface.status, "ready");
|
|
203
|
+
if (result.surface.status !== "ready") return;
|
|
204
|
+
assert.equal(result.surface.section.status, "available");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("no update available reads as up to date", () => {
|
|
208
|
+
const result = deriveUpdateSurface(
|
|
209
|
+
input({
|
|
210
|
+
data: response({
|
|
211
|
+
check: {
|
|
212
|
+
status: "ok",
|
|
213
|
+
updateAvailable: false,
|
|
214
|
+
lastCheckedAt: "2026-07-20T11:40:00.000Z",
|
|
215
|
+
},
|
|
216
|
+
}),
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
assert.equal(
|
|
220
|
+
result.surface.status === "ready" && result.surface.section.status,
|
|
221
|
+
"upToDate",
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a configured surface that has not checked yet is checking", () => {
|
|
226
|
+
const result = deriveUpdateSurface(
|
|
227
|
+
input({ data: response({ check: { status: "disabled" } }) }),
|
|
228
|
+
);
|
|
229
|
+
assert.equal(
|
|
230
|
+
result.surface.status === "ready" && result.surface.section.status,
|
|
231
|
+
"checking",
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a pressed check shows checking while the refetch is in flight", () => {
|
|
236
|
+
const result = deriveUpdateSurface(
|
|
237
|
+
input({
|
|
238
|
+
data: response(),
|
|
239
|
+
checkRequested: true,
|
|
240
|
+
isFetching: true,
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
assert.equal(
|
|
244
|
+
result.surface.status === "ready" && result.surface.section.status,
|
|
245
|
+
"checking",
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("deriveUpdateSurface — check and run stay independent", () => {
|
|
251
|
+
test("a failed check renders as a failed check, never a failed update", () => {
|
|
252
|
+
const result = deriveUpdateSurface(
|
|
253
|
+
input({
|
|
254
|
+
data: response({
|
|
255
|
+
check: { status: "failed", error: "no route to github.com" },
|
|
256
|
+
run: null,
|
|
257
|
+
}),
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
assert.equal(result.surface.status, "ready");
|
|
261
|
+
if (result.surface.status !== "ready") return;
|
|
262
|
+
assert.equal(result.surface.section.status, "checkFailed");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("a failed check does not override a run that finished", () => {
|
|
266
|
+
const result = deriveUpdateSurface(
|
|
267
|
+
input({
|
|
268
|
+
data: response({
|
|
269
|
+
check: { status: "failed", error: "no route to github.com" },
|
|
270
|
+
run: run({ outcome: "succeeded" }),
|
|
271
|
+
}),
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
assert.equal(
|
|
275
|
+
result.surface.status === "ready" && result.surface.section.status,
|
|
276
|
+
"succeeded",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("deriveUpdateSurface — terminal outcomes", () => {
|
|
282
|
+
test("a rolledBack run renders message and command verbatim and clears the id", () => {
|
|
283
|
+
const result = deriveUpdateSurface(
|
|
284
|
+
input({
|
|
285
|
+
data: response({
|
|
286
|
+
run: run({
|
|
287
|
+
outcome: "rolledBack",
|
|
288
|
+
message: "migration 0042 failed",
|
|
289
|
+
logCommand: "remit logs --since 30m",
|
|
290
|
+
}),
|
|
291
|
+
}),
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
assert.equal(result.clearStoredRun, true);
|
|
295
|
+
assert.equal(result.surface.status, "ready");
|
|
296
|
+
if (result.surface.status !== "ready") return;
|
|
297
|
+
const section = result.surface.section;
|
|
298
|
+
assert.equal(section.status, "rolledBack");
|
|
299
|
+
if (section.status !== "rolledBack") return;
|
|
300
|
+
assert.equal(section.reason, "migration 0042 failed");
|
|
301
|
+
assert.equal(section.logsCommand, "remit logs --since 30m");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("a rollbackFailed run renders message and command verbatim", () => {
|
|
305
|
+
const result = deriveUpdateSurface(
|
|
306
|
+
input({
|
|
307
|
+
data: response({
|
|
308
|
+
run: run({
|
|
309
|
+
outcome: "rollbackFailed",
|
|
310
|
+
message: "restore errored: database is locked",
|
|
311
|
+
logCommand: "remit logs --since 1h",
|
|
312
|
+
}),
|
|
313
|
+
}),
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
assert.equal(result.surface.status, "ready");
|
|
317
|
+
if (result.surface.status !== "ready") return;
|
|
318
|
+
const section = result.surface.section;
|
|
319
|
+
assert.equal(section.status, "rollbackFailed");
|
|
320
|
+
if (section.status !== "rollbackFailed") return;
|
|
321
|
+
assert.equal(section.reason, "restore errored: database is locked");
|
|
322
|
+
assert.equal(section.logsCommand, "remit logs --since 1h");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("an abandoned run reports the running version unchanged", () => {
|
|
326
|
+
const result = deriveUpdateSurface(
|
|
327
|
+
input({ data: response({ run: run({ outcome: "abandoned" }) }) }),
|
|
328
|
+
);
|
|
329
|
+
const section =
|
|
330
|
+
result.surface.status === "ready" ? result.surface.section : null;
|
|
331
|
+
assert.equal(section?.status, "abandoned");
|
|
332
|
+
assert.equal(section?.status === "abandoned" && section.version, "0.9.3");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("a dismissed result falls back to the check block", () => {
|
|
336
|
+
const result = deriveUpdateSurface(
|
|
337
|
+
input({
|
|
338
|
+
data: response({
|
|
339
|
+
check: { status: "ok", updateAvailable: false },
|
|
340
|
+
run: run({ runId: "upd_9", outcome: "succeeded" }),
|
|
341
|
+
}),
|
|
342
|
+
dismissedRunId: "upd_9",
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
assert.equal(
|
|
346
|
+
result.surface.status === "ready" && result.surface.section.status,
|
|
347
|
+
"upToDate",
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("deriveUpdateSurface — a run this client never started", () => {
|
|
353
|
+
test("an in-flight run renders applying, no held id needed", () => {
|
|
354
|
+
const result = deriveUpdateSurface(
|
|
355
|
+
input({ data: response({ run: run({ outcome: null }) }) }),
|
|
356
|
+
);
|
|
357
|
+
assert.equal(result.surface.status, "ready");
|
|
358
|
+
if (result.surface.status !== "ready") return;
|
|
359
|
+
assert.equal(result.surface.section.status, "applying");
|
|
360
|
+
assert.equal(result.surface.overlay.kind, "applying");
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("deriveUpdateSurface — a held run across a restart", () => {
|
|
365
|
+
test("a failed request with a held run is applying, not unreachable", () => {
|
|
366
|
+
const result = deriveUpdateSurface(
|
|
367
|
+
input({
|
|
368
|
+
isError: true,
|
|
369
|
+
error: new Error("connection refused"),
|
|
370
|
+
held: held({ startedAt: NOW - 20_000 }),
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
373
|
+
assert.equal(result.surface.status, "ready");
|
|
374
|
+
if (result.surface.status !== "ready") return;
|
|
375
|
+
assert.equal(result.surface.section.status, "applying");
|
|
376
|
+
assert.equal(result.surface.overlay.kind, "applying");
|
|
377
|
+
assert.equal(result.clearStoredRun, false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("a failed request without a held run is a check-level failure", () => {
|
|
381
|
+
const result = deriveUpdateSurface(
|
|
382
|
+
input({
|
|
383
|
+
data: response(),
|
|
384
|
+
isError: true,
|
|
385
|
+
error: new Error("connection refused"),
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
assert.equal(
|
|
389
|
+
result.surface.status === "ready" && result.surface.section.status,
|
|
390
|
+
"checkFailed",
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("the budget plus margin elapsing flips applying to never-came-back", () => {
|
|
395
|
+
const result = deriveUpdateSurface(
|
|
396
|
+
input({
|
|
397
|
+
isError: true,
|
|
398
|
+
error: new Error("connection refused"),
|
|
399
|
+
held: held({ startedAt: NOW - BUDGET_MS - 60_000 }),
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
assert.equal(result.surface.status, "ready");
|
|
403
|
+
if (result.surface.status !== "ready") return;
|
|
404
|
+
assert.equal(result.surface.overlay.kind, "neverCameBack");
|
|
405
|
+
assert.equal(result.clearStoredRun, true);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("never-came-back never claims the rollback ran", () => {
|
|
409
|
+
const result = deriveUpdateSurface(
|
|
410
|
+
input({
|
|
411
|
+
isError: true,
|
|
412
|
+
error: new Error("connection refused"),
|
|
413
|
+
held: held({ startedAt: NOW - BUDGET_MS - 60_000 }),
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
if (
|
|
417
|
+
result.surface.status !== "ready" ||
|
|
418
|
+
result.surface.overlay.kind !== "neverCameBack"
|
|
419
|
+
) {
|
|
420
|
+
assert.fail("expected the never-came-back overlay");
|
|
421
|
+
}
|
|
422
|
+
// The overlay carries only what is known: attempted and previous versions,
|
|
423
|
+
// and a command. No success, no rollback verdict.
|
|
424
|
+
assert.equal(result.surface.overlay.attemptedVersion, "0.9.4");
|
|
425
|
+
assert.equal(result.surface.overlay.previousVersion, "0.9.3");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("the server answering with the matching run in flight shows its phase", () => {
|
|
429
|
+
const result = deriveUpdateSurface(
|
|
430
|
+
input({
|
|
431
|
+
data: response({ run: run({ phase: "verifying", outcome: null }) }),
|
|
432
|
+
held: held(),
|
|
433
|
+
}),
|
|
434
|
+
);
|
|
435
|
+
if (
|
|
436
|
+
result.surface.status !== "ready" ||
|
|
437
|
+
result.surface.overlay.kind !== "applying"
|
|
438
|
+
) {
|
|
439
|
+
assert.fail("expected the applying overlay");
|
|
440
|
+
}
|
|
441
|
+
assert.equal(result.surface.overlay.phase, "reconnecting");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("the matching run resolving terminally clears the held id", () => {
|
|
445
|
+
const result = deriveUpdateSurface(
|
|
446
|
+
input({
|
|
447
|
+
data: response({ run: run({ outcome: "succeeded" }) }),
|
|
448
|
+
held: held(),
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
assert.equal(result.releaseHeld, true);
|
|
452
|
+
assert.equal(result.clearStoredRun, true);
|
|
453
|
+
assert.equal(
|
|
454
|
+
result.surface.status === "ready" && result.surface.section.status,
|
|
455
|
+
"succeeded",
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("a long silence that the server cannot account for reads as unreachable", () => {
|
|
460
|
+
const result = deriveUpdateSurface(
|
|
461
|
+
input({
|
|
462
|
+
data: response({ run: null }),
|
|
463
|
+
held: held({ startedAt: NOW - BUDGET_MS - 60_000 }),
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
assert.equal(
|
|
467
|
+
result.surface.status === "ready" && result.surface.section.status,
|
|
468
|
+
"unreachable",
|
|
469
|
+
);
|
|
470
|
+
assert.equal(result.releaseHeld, true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("an early server answer with no run yet keeps applying", () => {
|
|
474
|
+
const result = deriveUpdateSurface(
|
|
475
|
+
input({ data: response({ run: null }), held: held() }),
|
|
476
|
+
);
|
|
477
|
+
assert.equal(
|
|
478
|
+
result.surface.status === "ready" && result.surface.overlay.kind,
|
|
479
|
+
"applying",
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
function installMemoryStorage(): void {
|
|
485
|
+
const store = new Map<string, string>();
|
|
486
|
+
globalThis.localStorage = {
|
|
487
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
488
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
489
|
+
removeItem: (k: string) => void store.delete(k),
|
|
490
|
+
clear: () => store.clear(),
|
|
491
|
+
key: () => null,
|
|
492
|
+
length: 0,
|
|
493
|
+
} as Storage;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
describe("held-run persistence", () => {
|
|
497
|
+
beforeEach(installMemoryStorage);
|
|
498
|
+
|
|
499
|
+
test("saves, loads and clears under the documented key", () => {
|
|
500
|
+
const record = held();
|
|
501
|
+
saveHeldRun(record);
|
|
502
|
+
assert.equal(
|
|
503
|
+
localStorage.getItem(SELF_UPDATE_RUN_KEY),
|
|
504
|
+
JSON.stringify(record),
|
|
505
|
+
);
|
|
506
|
+
assert.deepEqual(loadHeldRun(), record);
|
|
507
|
+
clearStoredRun();
|
|
508
|
+
assert.equal(loadHeldRun(), null);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("ignores a malformed stored value", () => {
|
|
512
|
+
localStorage.setItem(SELF_UPDATE_RUN_KEY, "{not json");
|
|
513
|
+
assert.equal(loadHeldRun(), null);
|
|
514
|
+
localStorage.setItem(SELF_UPDATE_RUN_KEY, JSON.stringify({ runId: 1 }));
|
|
515
|
+
assert.equal(loadHeldRun(), null);
|
|
516
|
+
});
|
|
517
|
+
});
|