@remit/web-client 0.0.95 → 0.0.97

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.95",
3
+ "version": "0.0.97",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -71,7 +71,6 @@
71
71
  "@tanstack/react-query-devtools": "^5",
72
72
  "@tanstack/react-router": "^1",
73
73
  "@tanstack/react-virtual": "^3.13.18",
74
- "@tanstack/zod-adapter": "^1",
75
74
  "aws-amplify": "^6",
76
75
  "@aws-amplify/ui-react": "^6",
77
76
  "aws-rum-web": "^3",
@@ -26,12 +26,15 @@ import {
26
26
 
27
27
  let harness: DomHarness | undefined;
28
28
  let http: HttpMock | undefined;
29
+ let restoreFetch: (() => void) | undefined;
29
30
 
30
31
  afterEach(() => {
31
32
  harness?.close();
32
33
  harness = undefined;
33
34
  http?.restore();
34
35
  http = undefined;
36
+ restoreFetch?.();
37
+ restoreFetch = undefined;
35
38
  });
36
39
 
37
40
  interface MailboxProgress {
@@ -72,22 +75,83 @@ const accountOf = (path: string): string => path.split("/")[2] ?? "";
72
75
  const progress = (dom: DomHarness): InitialSyncProgress =>
73
76
  JSON.parse(dom.text());
74
77
 
75
- const mount = async (
78
+ const UNKNOWN: InitialSyncProgress = {
79
+ syncing: false,
80
+ resolved: false,
81
+ synced: 0,
82
+ total: 0,
83
+ };
84
+
85
+ /** How long the hook gets to answer a request that is already in hand. */
86
+ const RESOLVE_CEILING_MS = 2000;
87
+ /** How long the hook is watched for an answer, or a request, it must not give. */
88
+ const QUIET_WINDOW_MS = 500;
89
+ const STEP_MS = 10;
90
+
91
+ /**
92
+ * Let real time pass until the hook itself says `done`, or the ceiling runs out.
93
+ *
94
+ * Every wait in this file is a question put to the hook rather than a count of
95
+ * turns or a sleep the answer is assumed to fit inside: the poll and the answer
96
+ * deadline run on real timers, so a loaded machine changes how much work lands
97
+ * per turn, and the ceiling is what keeps a hook that never answers a failure
98
+ * instead of a hang.
99
+ */
100
+ const settleUntil = async (
101
+ dom: DomHarness,
102
+ done: () => boolean,
103
+ ceilingMs: number,
104
+ ): Promise<void> => {
105
+ const deadline = Date.now() + ceilingMs;
106
+ for (;;) {
107
+ await dom.flush();
108
+ if (done() || Date.now() >= deadline) return;
109
+ await dom.wait(STEP_MS);
110
+ }
111
+ };
112
+
113
+ const mount = (
76
114
  accountIds: string[],
77
115
  answer: (accountId: string) => unknown,
78
116
  enabled = true,
79
- ): Promise<DomHarness> => {
117
+ ): DomHarness => {
80
118
  http = mockFetch((call) => answer(accountOf(call.path)));
81
119
  harness = createDomHarness();
82
120
  harness.renderApp(createElement(Probe, { accountIds, enabled }));
83
- // Settle on the hook's own answer rather than on a fixed number of turns:
84
- // a query that lands on a timer instead of a microtask needs real time to
85
- // pass, and a loaded machine is where that difference shows up.
86
- for (let turn = 0; turn < 20; turn += 1) {
87
- await harness.flush();
88
- if (progress(harness).resolved) break;
89
- await harness.wait(1);
90
- }
121
+ return harness;
122
+ };
123
+
124
+ const mountResolved = async (
125
+ accountIds: string[],
126
+ answer: (accountId: string) => unknown,
127
+ ): Promise<DomHarness> => {
128
+ const dom = mount(accountIds, answer);
129
+ await settleUntil(dom, () => progress(dom).resolved, RESOLVE_CEILING_MS);
130
+ return dom;
131
+ };
132
+
133
+ /** `acc-2` never answers at all: its request is left hanging for good. */
134
+ const mountSilentAccount = (
135
+ answerOfAccountOne: RemitImapAccountSyncStatusResponse,
136
+ ): DomHarness => {
137
+ const original = globalThis.fetch;
138
+ restoreFetch = () => {
139
+ globalThis.fetch = original;
140
+ };
141
+ globalThis.fetch = ((input: RequestInfo | URL) => {
142
+ const url = input instanceof Request ? input.url : String(input);
143
+ if (url.includes("acc-2")) return new Promise<Response>(() => {});
144
+ return Promise.resolve(
145
+ new Response(JSON.stringify(answerOfAccountOne), {
146
+ status: 200,
147
+ headers: { "content-type": "application/json" },
148
+ }),
149
+ );
150
+ }) as typeof globalThis.fetch;
151
+ harness = createDomHarness();
152
+ harness.renderApp(
153
+ createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
154
+ );
91
155
  return harness;
92
156
  };
93
157
 
@@ -112,7 +176,7 @@ describe("isSyncingPhase", () => {
112
176
 
113
177
  describe("useInitialSyncProgress", () => {
114
178
  it("reports syncing with the counts of the accounts still syncing", async () => {
115
- const dom = await mount(["acc-1"], (accountId) =>
179
+ const dom = await mountResolved(["acc-1"], (accountId) =>
116
180
  status(accountId, "syncing_inbox", [
117
181
  { synced: 40, total: 100 },
118
182
  { synced: 10, total: 60 },
@@ -127,7 +191,7 @@ describe("useInitialSyncProgress", () => {
127
191
  });
128
192
 
129
193
  it("leaves out the counts of an account that has finished", async () => {
130
- const dom = await mount(["acc-1", "acc-2"], (accountId) =>
194
+ const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
131
195
  accountId === "acc-1"
132
196
  ? status(accountId, "syncing_inbox", [{ synced: 40, total: 100 }])
133
197
  : status(accountId, "complete", [{ synced: 900, total: 900 }]),
@@ -141,7 +205,7 @@ describe("useInitialSyncProgress", () => {
141
205
  });
142
206
 
143
207
  it("resolves to not-syncing once every account is done", async () => {
144
- const dom = await mount(["acc-1", "acc-2"], (accountId) =>
208
+ const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
145
209
  status(accountId, "complete", [{ synced: 900, total: 900 }]),
146
210
  );
147
211
  assert.deepEqual(progress(dom), {
@@ -153,7 +217,7 @@ describe("useInitialSyncProgress", () => {
153
217
  });
154
218
 
155
219
  it("counts an unreachable account as answered rather than holding the list in limbo", async () => {
156
- const dom = await mount(["acc-1", "acc-2"], (accountId) =>
220
+ const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
157
221
  accountId === "acc-1"
158
222
  ? status(accountId, "syncing_inbox", [{ synced: 5, total: 50 }])
159
223
  : httpError(503),
@@ -167,39 +231,17 @@ describe("useInitialSyncProgress", () => {
167
231
  it("knows nothing until every account has answered", async () => {
168
232
  // One account answers, the other never does — the hook must not report on
169
233
  // the half it has.
170
- const original = globalThis.fetch;
171
- try {
172
- globalThis.fetch = ((input: RequestInfo | URL) => {
173
- const url = input instanceof Request ? input.url : String(input);
174
- if (url.includes("acc-2")) return new Promise<Response>(() => {});
175
- return Promise.resolve(
176
- new Response(
177
- JSON.stringify(
178
- status("acc-1", "syncing_inbox", [{ synced: 5, total: 50 }]),
179
- ),
180
- { status: 200, headers: { "content-type": "application/json" } },
181
- ),
182
- );
183
- }) as typeof globalThis.fetch;
184
- harness = createDomHarness();
185
- harness.renderApp(
186
- createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
187
- );
188
- await harness.flush();
189
- await harness.flush();
190
- assert.deepEqual(progress(harness), {
191
- syncing: false,
192
- resolved: false,
193
- synced: 0,
194
- total: 0,
195
- });
196
- } finally {
197
- globalThis.fetch = original;
198
- }
234
+ const dom = mountSilentAccount(
235
+ status("acc-1", "syncing_inbox", [{ synced: 5, total: 50 }]),
236
+ );
237
+
238
+ await settleUntil(dom, () => progress(dom).resolved, QUIET_WINDOW_MS);
239
+
240
+ assert.deepEqual(progress(dom), UNKNOWN);
199
241
  });
200
242
 
201
243
  it("answers immediately, and never syncing, for no accounts at all", async () => {
202
- const dom = await mount([], () => ({}));
244
+ const dom = await mountResolved([], () => ({}));
203
245
  assert.deepEqual(progress(dom), {
204
246
  syncing: false,
205
247
  resolved: true,
@@ -211,24 +253,28 @@ describe("useInitialSyncProgress", () => {
211
253
 
212
254
  it("stops asking once every account has answered and none is syncing", async () => {
213
255
  // The caught-up user's answer cannot change by being asked again.
214
- const dom = await mount(["acc-1", "acc-2"], (accountId) =>
256
+ const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
215
257
  status(accountId, "complete"),
216
258
  );
217
259
  const asked = http?.calls.length ?? 0;
218
260
  assert.equal(asked, 2);
219
261
 
220
- await dom.wait(POLL_MS + 500);
262
+ await settleUntil(
263
+ dom,
264
+ () => (http?.calls.length ?? 0) > asked,
265
+ POLL_MS + QUIET_WINDOW_MS,
266
+ );
221
267
 
222
268
  assert.equal(http?.calls.length, asked);
223
269
  assert.equal(progress(dom).resolved, true);
224
270
  });
225
271
 
226
272
  it("keeps asking while an account is still syncing", async () => {
227
- const dom = await mount(["acc-1"], (accountId) =>
273
+ const dom = await mountResolved(["acc-1"], (accountId) =>
228
274
  status(accountId, "syncing_inbox", [{ synced: 5, total: 50 }]),
229
275
  );
230
276
 
231
- await dom.wait(POLL_MS + 500);
277
+ await settleUntil(dom, () => (http?.calls.length ?? 0) > 1, POLL_MS * 3);
232
278
 
233
279
  assert.ok(
234
280
  (http?.calls.length ?? 0) > 1,
@@ -237,50 +283,31 @@ describe("useInitialSyncProgress", () => {
237
283
  });
238
284
 
239
285
  it("answers without an account that never responds", async () => {
240
- const original = globalThis.fetch;
241
- try {
242
- globalThis.fetch = ((input: RequestInfo | URL) => {
243
- const url = input instanceof Request ? input.url : String(input);
244
- if (url.includes("acc-2")) return new Promise<Response>(() => {});
245
- return Promise.resolve(
246
- new Response(JSON.stringify(status("acc-1", "complete")), {
247
- status: 200,
248
- headers: { "content-type": "application/json" },
249
- }),
250
- );
251
- }) as typeof globalThis.fetch;
252
- harness = createDomHarness();
253
- harness.renderApp(
254
- createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
255
- );
256
- await harness.flush();
257
- assert.equal(progress(harness).resolved, false);
258
-
259
- await harness.wait(ANSWER_DEADLINE_MS + 500);
260
-
261
- assert.deepEqual(progress(harness), {
262
- syncing: false,
263
- resolved: true,
264
- synced: 0,
265
- total: 0,
266
- });
267
- } finally {
268
- globalThis.fetch = original;
269
- }
270
- });
286
+ const dom = mountSilentAccount(status("acc-1", "complete"));
271
287
 
272
- it("asks nothing and claims nothing while disabled", async () => {
273
- const dom = await mount(
274
- ["acc-1"],
275
- () => status("acc-1", "syncing_inbox"),
276
- false,
288
+ await settleUntil(dom, () => progress(dom).resolved, QUIET_WINDOW_MS);
289
+ assert.equal(progress(dom).resolved, false);
290
+
291
+ await settleUntil(
292
+ dom,
293
+ () => progress(dom).resolved,
294
+ ANSWER_DEADLINE_MS * 2,
277
295
  );
296
+
278
297
  assert.deepEqual(progress(dom), {
279
298
  syncing: false,
280
- resolved: false,
299
+ resolved: true,
281
300
  synced: 0,
282
301
  total: 0,
283
302
  });
303
+ });
304
+
305
+ it("asks nothing and claims nothing while disabled", async () => {
306
+ const dom = mount(["acc-1"], () => status("acc-1", "syncing_inbox"), false);
307
+
308
+ await settleUntil(dom, () => progress(dom).resolved, QUIET_WINDOW_MS);
309
+
310
+ assert.deepEqual(progress(dom), UNKNOWN);
284
311
  assert.deepEqual(http?.calls ?? [], []);
285
312
  });
286
313
  });