@remit/web-client 0.0.270 → 0.0.272

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.
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The update poll against the app's own fail-fast sink (#468).
3
+ *
4
+ * An update stops and starts the backend on purpose, so the proxy in front of
5
+ * it answers 502 for the seconds it is gone. Every other test in this package
6
+ * mounts on the harness's plain `QueryClient`, which has no global sink, so none
7
+ * of them can see that the 502 took the whole screen before the update surface
8
+ * ever read it. Mounted here on the client `shell/index.tsx` builds.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, beforeEach, describe, test } from "node:test";
13
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
14
+ import { act, createElement } from "react";
15
+ import { SelfUpdateOverlay } from "@/components/self-update/SelfUpdateOverlay";
16
+ import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
17
+ import {
18
+ handleMutationCacheError,
19
+ handleQueryCacheError,
20
+ } from "@/lib/query-error-handler";
21
+ import {
22
+ APPLY_BUDGET_SECONDS,
23
+ NEVER_CAME_BACK_MARGIN_SECONDS,
24
+ } from "@/lib/self-update-state";
25
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
26
+ import { type HttpMock, httpError, mockFetch } from "../test-support/http";
27
+ import {
28
+ type SelfUpdateApi,
29
+ SelfUpdateProvider,
30
+ useSelfUpdate,
31
+ } from "./use-system-update";
32
+ import "@/lib/client";
33
+
34
+ const BUDGET_MS =
35
+ (APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS) * 1000;
36
+
37
+ let harness: DomHarness | undefined;
38
+ let http: HttpMock | undefined;
39
+ let api: SelfUpdateApi | undefined;
40
+
41
+ beforeEach(() => {
42
+ __resetFatalError();
43
+ });
44
+
45
+ afterEach(() => {
46
+ __resetFatalError();
47
+ http?.restore();
48
+ http = undefined;
49
+ harness?.close();
50
+ harness = undefined;
51
+ api = undefined;
52
+ });
53
+
54
+ const available = {
55
+ currentVersion: "0.9.3",
56
+ check: {
57
+ status: "ok",
58
+ updateAvailable: true,
59
+ latestVersion: "0.9.4",
60
+ publishedAt: "2026-07-14T09:00:00.000Z",
61
+ summary: "Faster first sync.",
62
+ releaseNotesUrl: "https://example.test/notes",
63
+ },
64
+ run: null,
65
+ };
66
+
67
+ const accepted = {
68
+ currentVersion: "0.9.3",
69
+ check: { status: "ok", updateAvailable: true, latestVersion: "0.9.4" },
70
+ run: {
71
+ runId: "upd_1",
72
+ fromVersion: "0.9.3",
73
+ targetVersion: "0.9.4",
74
+ phase: "stopping",
75
+ outcome: null,
76
+ startedAt: "2026-07-20T11:59:30.000Z",
77
+ updatedAt: "2026-07-20T11:59:45.000Z",
78
+ message: "Restarting Remit on 0.9.4.",
79
+ logCommand: "remit logs --since 10m",
80
+ },
81
+ };
82
+
83
+ /**
84
+ * The run as a tab that never pressed install finds it: reported by the server,
85
+ * started just now, so the wait is measured from a start the page did not see.
86
+ */
87
+ const reportedRunning = () => ({
88
+ ...accepted,
89
+ run: { ...accepted.run, startedAt: new Date().toISOString() },
90
+ });
91
+
92
+ /** The client `shell/index.tsx` builds: the real global escalation sink. */
93
+ const appQueryClient = (): QueryClient =>
94
+ new QueryClient({
95
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
96
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
97
+ defaultOptions: {
98
+ queries: { retry: false },
99
+ mutations: { retry: false },
100
+ },
101
+ });
102
+
103
+ const hook = (): SelfUpdateApi => {
104
+ if (!api) throw new Error("hook not mounted");
105
+ return api;
106
+ };
107
+
108
+ const dom = (): DomHarness => {
109
+ if (!harness) throw new Error("nothing mounted");
110
+ return harness;
111
+ };
112
+
113
+ /** Mount the surface, with `poll` deciding what each `GET /system/update` answers. */
114
+ async function mount(poll: () => unknown): Promise<void> {
115
+ http = mockFetch((call) => {
116
+ if (call.path.endsWith("/system/update") && call.method === "GET") {
117
+ return poll();
118
+ }
119
+ return accepted;
120
+ });
121
+ const Probe = () => {
122
+ api = useSelfUpdate();
123
+ return null;
124
+ };
125
+ harness = createDomHarness({ queryClient: appQueryClient() });
126
+ harness.renderApp(
127
+ createElement(
128
+ SelfUpdateProvider,
129
+ null,
130
+ createElement(Probe),
131
+ createElement(SelfUpdateOverlay),
132
+ ),
133
+ );
134
+ await settle();
135
+ }
136
+
137
+ async function settle(): Promise<void> {
138
+ for (let round = 0; round < 40; round += 1) {
139
+ await dom().flush();
140
+ await dom().wait(0);
141
+ }
142
+ }
143
+
144
+ /** Press install and wait for the run this page now holds to reach the surface. */
145
+ async function install(): Promise<void> {
146
+ await act(async () => {
147
+ hook().install("0.9.4");
148
+ await dom().flush();
149
+ });
150
+ await dom().waitFor(() => {
151
+ const surface = hook().surface;
152
+ return surface.status === "ready" && surface.overlay.kind === "applying";
153
+ }, "the install to be accepted");
154
+ }
155
+
156
+ async function repoll(): Promise<void> {
157
+ await act(async () => {
158
+ hook().onRetryConnection();
159
+ await dom().flush();
160
+ });
161
+ await settle();
162
+ }
163
+
164
+ describe("the update poll across the restart it asked for (#468)", () => {
165
+ test("a 502 while a run is held keeps the update surface, not the fatal page", async () => {
166
+ let restarting = false;
167
+ await mount(() => (restarting ? httpError(502) : available));
168
+ await install();
169
+
170
+ restarting = true;
171
+ await repoll();
172
+
173
+ assert.equal(getCurrentFatalError(), null);
174
+ const surface = hook().surface;
175
+ assert.equal(
176
+ surface.status === "ready" &&
177
+ surface.section.status === "applying" &&
178
+ surface.section.phase,
179
+ "reconnecting",
180
+ );
181
+ assert.match(dom().html(), /Installing Remit 0\.9\.4/);
182
+ });
183
+
184
+ test("the same 502 with no run held escalates", async () => {
185
+ await mount(() => httpError(502));
186
+
187
+ assert.equal(getCurrentFatalError()?.error !== undefined, true);
188
+ });
189
+
190
+ test("a 502 with no run known from either source escalates", async () => {
191
+ // The server has answered, and what it said was that nothing is running.
192
+ let restarting = false;
193
+ await mount(() => (restarting ? httpError(502) : available));
194
+
195
+ restarting = true;
196
+ await repoll();
197
+
198
+ assert.equal(getCurrentFatalError()?.error !== undefined, true);
199
+ });
200
+
201
+ test("a connection refused inside the window stays soft, as any transport failure does", async () => {
202
+ let restarting = false;
203
+ await mount(() => {
204
+ if (restarting) throw new Error("connection refused");
205
+ return available;
206
+ });
207
+ await install();
208
+
209
+ restarting = true;
210
+ await repoll();
211
+
212
+ assert.equal(getCurrentFatalError(), null);
213
+ assert.match(dom().html(), /Installing Remit 0\.9\.4/);
214
+ });
215
+
216
+ test("a 500 while a run is held escalates — the server answered", async () => {
217
+ let broken = false;
218
+ await mount(() => (broken ? httpError(500) : available));
219
+ await install();
220
+
221
+ broken = true;
222
+ await repoll();
223
+
224
+ assert.equal(getCurrentFatalError()?.error !== undefined, true);
225
+ });
226
+
227
+ test("a 502 past the apply budget gives up loudly, still not fatally", async () => {
228
+ let restarting = false;
229
+ await mount(() => (restarting ? httpError(502) : available));
230
+ await install();
231
+
232
+ restarting = true;
233
+ const realNow = Date.now;
234
+ Date.now = () => realNow() + BUDGET_MS + 60_000;
235
+ try {
236
+ await repoll();
237
+
238
+ assert.equal(getCurrentFatalError(), null);
239
+ assert.match(dom().html(), /has not answered since the restart/);
240
+ assert.match(dom().html(), /remit logs/);
241
+ assert.doesNotMatch(dom().html(), /Installing Remit 0\.9\.4/);
242
+ } finally {
243
+ Date.now = realNow;
244
+ }
245
+ });
246
+ });
247
+
248
+ /**
249
+ * The overlay speaks for a run in any tab the server tells about it, so the
250
+ * restart has to be ridden there too — a second tab, or the initiating one after
251
+ * a reload, holds nothing of its own.
252
+ */
253
+ describe("a tab that never pressed install (#468)", () => {
254
+ test("holds the applying overlay across a 502", async () => {
255
+ let restarting = false;
256
+ await mount(() => (restarting ? httpError(502) : reportedRunning()));
257
+ assert.match(dom().html(), /Installing Remit 0\.9\.4/);
258
+
259
+ restarting = true;
260
+ await repoll();
261
+
262
+ assert.equal(getCurrentFatalError(), null);
263
+ assert.match(dom().html(), /Installing Remit 0\.9\.4/);
264
+ });
265
+
266
+ test("gives up loudly past the same apply budget, and names the run's own log command", async () => {
267
+ let restarting = false;
268
+ await mount(() => (restarting ? httpError(502) : reportedRunning()));
269
+
270
+ restarting = true;
271
+ const realNow = Date.now;
272
+ Date.now = () => realNow() + BUDGET_MS + 60_000;
273
+ try {
274
+ await repoll();
275
+
276
+ assert.equal(getCurrentFatalError(), null);
277
+ assert.match(dom().html(), /has not answered since the restart/);
278
+ assert.match(dom().html(), /remit logs --since 10m/);
279
+ assert.doesNotMatch(dom().html(), /Installing Remit 0\.9\.4/);
280
+ } finally {
281
+ Date.now = realNow;
282
+ }
283
+ });
284
+
285
+ test("a box clock behind the browser's does not spend the budget before the first 502", async () => {
286
+ // `startedAt` is written on the box. Twenty minutes of skew is more than
287
+ // the whole budget, so measuring the wait from it would open the screen on
288
+ // the give-up and never show the install at all.
289
+ const skewed = () => ({
290
+ ...accepted.run,
291
+ startedAt: new Date(Date.now() - BUDGET_MS - 60_000).toISOString(),
292
+ });
293
+ let restarting = false;
294
+ await mount(() =>
295
+ restarting ? httpError(502) : { ...accepted, run: skewed() },
296
+ );
297
+
298
+ restarting = true;
299
+ await repoll();
300
+
301
+ assert.equal(getCurrentFatalError(), null);
302
+ assert.match(dom().html(), /Installing Remit 0\.9\.4/);
303
+ assert.doesNotMatch(dom().html(), /has not answered since the restart/);
304
+
305
+ // The wait is this tab's, so it still runs out — on this tab's clock.
306
+ const realNow = Date.now;
307
+ Date.now = () => realNow() + BUDGET_MS + 60_000;
308
+ try {
309
+ await repoll();
310
+
311
+ assert.equal(getCurrentFatalError(), null);
312
+ assert.match(dom().html(), /has not answered since the restart/);
313
+ } finally {
314
+ Date.now = realNow;
315
+ }
316
+ });
317
+
318
+ test("says the service is unreachable once the run it knew about is finished", async () => {
319
+ let restarting = false;
320
+ await mount(() =>
321
+ restarting
322
+ ? httpError(502)
323
+ : {
324
+ currentVersion: "0.9.4",
325
+ check: { status: "ok", updateAvailable: false },
326
+ run: { ...accepted.run, outcome: "succeeded" },
327
+ },
328
+ );
329
+
330
+ restarting = true;
331
+ await repoll();
332
+
333
+ const surface = hook().surface;
334
+ assert.equal(
335
+ surface.status === "ready" && surface.section.status,
336
+ "checkFailed",
337
+ );
338
+ assert.equal(getCurrentFatalError()?.error !== undefined, true);
339
+ });
340
+ });
@@ -37,7 +37,11 @@ import {
37
37
  useRef,
38
38
  useState,
39
39
  } from "react";
40
- import { shouldEscalate, softErrorMeta } from "@/lib/error-classifier";
40
+ import {
41
+ restartExpectedMeta,
42
+ shouldEscalate,
43
+ softErrorMeta,
44
+ } from "@/lib/error-classifier";
41
45
  import { reportFatalError } from "@/lib/fatal-error";
42
46
  import {
43
47
  appliesSchemaMigration,
@@ -48,13 +52,30 @@ import {
48
52
  type HeldRun,
49
53
  isSurfaceAbsent,
50
54
  mapUpdatePhase,
55
+ type RunSighting,
51
56
  releaseFromCheck,
57
+ runInFlight,
52
58
  type UpdateSurface,
53
59
  } from "@/lib/self-update-state";
54
60
 
55
61
  const IDLE_POLL_MS = 30_000;
56
62
  const RUN_POLL_MS = 5_000;
57
63
 
64
+ /**
65
+ * The poll's own error UX, and — while a run is known to be in flight — the
66
+ * restart that run performs. Stopping and starting the backend is what the run
67
+ * is, so the gateway statuses answered while it is down belong to the surface
68
+ * below, which shows the phase and gives up loudly if the server never comes
69
+ * back (#468). Outside that window the poll carries no such claim.
70
+ *
71
+ * "Known" is either source: the run this page started and holds, or the run the
72
+ * server last reported as going. The overlay speaks for a run in any tab, so the
73
+ * window it rides has to open in any tab too — a second tab, or this one after a
74
+ * reload, holds nothing and would otherwise meet the restart with no claim on it.
75
+ */
76
+ const POLL_META = softErrorMeta;
77
+ const POLL_ACROSS_RESTART_META = { ...softErrorMeta, ...restartExpectedMeta };
78
+
58
79
  /**
59
80
  * How long a pressed check waits for the updater before the pane calls it a
60
81
  * failure. The backend only records the request; the updater picks it up on a
@@ -105,16 +126,25 @@ export function useSystemUpdate(): SelfUpdateApi {
105
126
  const [dismissedRunId, setDismissedRunId] = useState<string | null>(null);
106
127
  const [checkPress, setCheckPress] = useState<CheckPress | null>(null);
107
128
  const [checkFailure, setCheckFailure] = useState<string | null>(null);
129
+ const [sighting, setSighting] = useState<RunSighting | null>(null);
108
130
 
109
131
  const heldRef = useRef(held);
110
132
  heldRef.current = held;
111
133
  const pressRef = useRef(checkPress);
112
134
  pressRef.current = checkPress;
113
135
 
136
+ // The last answer, which outlives the failed requests after it: a tab that
137
+ // never pressed install learns from here that it is inside a restart.
138
+ const lastKnown = queryClient.getQueryData<RemitImapSystemUpdateResponse>(
139
+ systemOperationsGetSystemUpdateQueryKey(),
140
+ );
141
+ const insideRestart =
142
+ held !== null || runInFlight(lastKnown, dismissedRunId) !== null;
143
+
114
144
  const query = useQuery({
115
145
  ...systemOperationsGetSystemUpdateOptions(),
116
146
  retry: false,
117
- meta: { softError: true },
147
+ meta: insideRestart ? POLL_ACROSS_RESTART_META : POLL_META,
118
148
  refetchInterval: (query) =>
119
149
  pollInterval(
120
150
  query.state.error,
@@ -135,9 +165,26 @@ export function useSystemUpdate(): SelfUpdateApi {
135
165
  dismissedRunId,
136
166
  checkPress,
137
167
  checkFailure,
168
+ sighting,
138
169
  now: Date.now(),
139
170
  });
140
171
 
172
+ // Stamp the run the server reports, once, off this tab's clock. The stamp is
173
+ // what the give-up is measured against, so it must not move while the server
174
+ // is unreachable — and it must not carry over to the next run.
175
+ const reported = runInFlight(query.data, dismissedRunId);
176
+ useEffect(() => {
177
+ if (reported === null) {
178
+ setSighting((current) => (current === null ? current : null));
179
+ return;
180
+ }
181
+ setSighting((current) =>
182
+ current !== null && current.runId === reported.runId
183
+ ? current
184
+ : { runId: reported.runId, observedAt: Date.now() },
185
+ );
186
+ }, [reported]);
187
+
141
188
  const shownRunIdRef = useRef<string | null>(null);
142
189
  shownRunIdRef.current =
143
190
  derived.surface.status === "ready" && "runId" in derived.surface.section
@@ -1,8 +1,8 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
3
  import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
4
+ import { mailboxesTouchedBy } from "@/lib/bulk-actions";
4
5
  import { threadListCacheKeys } from "@/lib/thread-list-cache";
5
- import { mailboxesTouchedBy } from "./useEscalatedActions.js";
6
6
 
7
7
  describe("mailboxesTouchedBy", () => {
8
8
  test("a delete or mark-read run touches only the mailbox it ran over", () => {