@remit/web-client 0.0.271 → 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.
@@ -14,6 +14,7 @@
14
14
  import assert from "node:assert/strict";
15
15
  import { afterEach, beforeEach, describe, it } from "node:test";
16
16
  import { act, createElement } from "react";
17
+ import { ranOutcome } from "../test-support/bulk-run";
17
18
  import { createDomHarness, type DomHarness } from "../test-support/dom";
18
19
  import {
19
20
  type EscalationSearchQuery,
@@ -222,7 +223,7 @@ describe("an escalated run the user walks away from", () => {
222
223
  press(() => hook().clear());
223
224
  press(() => server?.release());
224
225
  await settle();
225
- const outcome = await run;
226
+ const outcome = ranOutcome(await run);
226
227
 
227
228
  assert.equal(outcome.cancelled, false);
228
229
  assert.equal(outcome.done, TOTAL);
@@ -238,7 +239,7 @@ describe("an escalated run the user walks away from", () => {
238
239
  setQuery({ query: "invoices" });
239
240
  press(() => server?.release());
240
241
  await settle();
241
- const outcome = await run;
242
+ const outcome = ranOutcome(await run);
242
243
 
243
244
  assert.equal(outcome.done, TOTAL);
244
245
  assert.deepEqual(
@@ -280,7 +281,7 @@ describe("stopping a run with a delete already on the wire", () => {
280
281
  press(() => hook().stop());
281
282
  press(() => server?.release());
282
283
  await settle();
283
- const outcome = await run;
284
+ const outcome = ranOutcome(await run);
284
285
 
285
286
  assert.equal(server?.aborted(), 1, "the delete in flight was cancelled");
286
287
  assert.equal(
@@ -326,7 +327,7 @@ describe("stopping the run, which is a different press", () => {
326
327
  press(() => hook().stop());
327
328
  press(() => server?.release());
328
329
  await settle();
329
- const outcome = await run;
330
+ const outcome = ranOutcome(await run);
330
331
 
331
332
  assert.equal(outcome.cancelled, true);
332
333
  assert.ok(outcome.done > 0, "the batches already sent still count");
@@ -11,32 +11,60 @@
11
11
  */
12
12
 
13
13
  import assert from "node:assert/strict";
14
- import { afterEach, describe, it } from "node:test";
14
+ import { afterEach, beforeEach, describe, it } from "node:test";
15
15
  import { QueryClientProvider } from "@tanstack/react-query";
16
- import { act, createElement } from "react";
16
+ import { act, createElement, type ReactNode, useEffect, useRef } from "react";
17
+ import { BulkRunProvider } from "@/components/mail/BulkRunProvider";
17
18
  import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
18
19
  import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
20
+ import type { BulkRunOutcome, EscalatedAction } from "../lib/bulk-actions";
19
21
  import { createDomHarness, type DomHarness } from "../test-support/dom";
20
- import { type HttpMock, mockFetch } from "../test-support/http";
22
+ import { type HttpCall, type HttpMock, mockFetch } from "../test-support/http";
21
23
  import {
22
24
  type UseEscalatedActionsResult,
23
25
  useEscalatedActions,
24
26
  } from "./useEscalatedActions";
25
27
 
26
28
  const INBOX = "mbx-inbox";
29
+ const ELSEWHERE = "mbx-archive";
27
30
  const ACCOUNT = "acc-1";
28
31
  const TRASH = "mbx-trash";
32
+ /** The one id whose delete is never answered, so a run holds the slot. */
33
+ const HELD = "msg-held";
34
+ const CONFIRM = "Set as Trash and delete";
35
+ const PICK_TRASH = "Set Prullenbak, 3 messages, as Trash";
29
36
 
30
37
  let harness: DomHarness | undefined;
31
38
  let http: HttpMock | undefined;
32
39
  let hook: UseEscalatedActionsResult | undefined;
33
40
 
41
+ /** Endings the run's owner stated once no screen was left to state them. */
42
+ let endings: Array<{
43
+ kind: EscalatedAction["kind"];
44
+ matched: number;
45
+ outcome: BulkRunOutcome;
46
+ }> = [];
47
+
48
+ interface MountedScreen {
49
+ hook: UseEscalatedActionsResult;
50
+ /** Passed to the run this screen starts, the way the wizard's does. */
51
+ claimEnding: (release: () => void) => void;
52
+ }
53
+
54
+ const screens = new Map<string, MountedScreen>();
55
+
56
+ beforeEach(() => {
57
+ endings = [];
58
+ screens.clear();
59
+ });
60
+
34
61
  afterEach(() => {
35
62
  harness?.close();
36
63
  harness = undefined;
37
64
  http?.restore();
38
65
  http = undefined;
39
66
  hook = undefined;
67
+ screens.clear();
40
68
  });
41
69
 
42
70
  const CONFIG = {
@@ -74,11 +102,12 @@ const Probe = () => {
74
102
  return null;
75
103
  };
76
104
 
77
- const settle = async (): Promise<void> => {
105
+ const settle = async (done: () => boolean = () => false): Promise<void> => {
78
106
  if (!harness) throw new Error("nothing mounted");
79
107
  for (let round = 0; round < 40; round += 1) {
80
108
  await harness.flush();
81
109
  await harness.wait(0);
110
+ if (done()) return;
82
111
  }
83
112
  };
84
113
 
@@ -95,7 +124,7 @@ const mount = async (respond: (path: string) => unknown): Promise<void> => {
95
124
  createElement(
96
125
  RoleAppointmentPromptProvider,
97
126
  null,
98
- createElement(Probe),
127
+ createElement(BulkRunProvider, null, createElement(Probe)),
99
128
  ),
100
129
  ),
101
130
  ),
@@ -154,3 +183,188 @@ describe("a bulk delete refused for its folder role", () => {
154
183
  assert.doesNotMatch(text, /Confirm this account's Trash folder/);
155
184
  });
156
185
  });
186
+
187
+ const MAILBOXES = {
188
+ items: [
189
+ {
190
+ mailboxId: INBOX,
191
+ accountId: ACCOUNT,
192
+ fullPath: "INBOX",
193
+ hierarchyDelimiter: "/",
194
+ messageCount: 12,
195
+ },
196
+ {
197
+ mailboxId: TRASH,
198
+ accountId: ACCOUNT,
199
+ fullPath: "Prullenbak",
200
+ hierarchyDelimiter: "/",
201
+ messageCount: 3,
202
+ },
203
+ ],
204
+ };
205
+
206
+ /**
207
+ * A mailbox screen that states a run's ending in place while it is up, and
208
+ * releases that claim when it goes — the wizard's own arrangement.
209
+ */
210
+ const screenFor = (mailboxId: string): ReactNode => {
211
+ const Screen = () => {
212
+ const release = useRef<(() => void) | undefined>(undefined);
213
+ useEffect(
214
+ () => () => {
215
+ release.current?.();
216
+ release.current = undefined;
217
+ },
218
+ [],
219
+ );
220
+ const live = useEscalatedActions({
221
+ mailboxId,
222
+ accountId: ACCOUNT,
223
+ enabled: true,
224
+ predicateKey: `${mailboxId}|npm`,
225
+ searchQuery: { query: "npm" },
226
+ reportEnding: (kind, matched, outcome) => {
227
+ endings.push({ kind, matched, outcome });
228
+ },
229
+ });
230
+ screens.set(mailboxId, {
231
+ hook: live,
232
+ claimEnding: (next) => {
233
+ release.current?.();
234
+ release.current = next;
235
+ },
236
+ });
237
+ return null;
238
+ };
239
+ return createElement(Screen, { key: mailboxId });
240
+ };
241
+
242
+ const screen = (mailboxId: string): MountedScreen => {
243
+ const mounted = screens.get(mailboxId);
244
+ if (!mounted) throw new Error(`no screen is mounted for ${mailboxId}`);
245
+ return mounted;
246
+ };
247
+
248
+ const mountApp = async (
249
+ respond: (call: HttpCall) => unknown,
250
+ element: ReactNode,
251
+ ): Promise<void> => {
252
+ http = mockFetch(respond);
253
+ harness = createDomHarness();
254
+ harness.renderApp(element);
255
+ await settle();
256
+ };
257
+
258
+ const deleteOne = (mailboxId: string, id: string, claimed: boolean): void => {
259
+ const mounted = screen(mailboxId);
260
+ void mounted.hook.runAction(
261
+ { kind: "delete" },
262
+ [{ id, accountId: ACCOUNT }],
263
+ claimed ? mounted.claimEnding : undefined,
264
+ );
265
+ };
266
+
267
+ const promptIsUp = (): boolean =>
268
+ (harness?.text() ?? "").includes("Confirm this account's Trash folder");
269
+
270
+ /** The confirm mounts only once the account's folders have arrived. */
271
+ const confirmIsUp = (): boolean =>
272
+ (harness?.queryAll("button") ?? []).some((button) =>
273
+ (button.textContent ?? "").includes(CONFIRM),
274
+ );
275
+
276
+ /** Pick the account's real Trash in the picker, then press the confirm. */
277
+ const pressConfirm = async (): Promise<void> => {
278
+ await settle(() => !!harness?.query(`[aria-label="${PICK_TRASH}"]`));
279
+ if (!harness) throw new Error("nothing mounted");
280
+ harness.click(harness.byLabel(PICK_TRASH));
281
+ await settle(confirmIsUp);
282
+ harness.click(harness.byText("button", CONFIRM));
283
+ };
284
+
285
+ /**
286
+ * The replay is a new run and claims nothing (#112). The claim the refused run
287
+ * was started under belongs to the screen that started it, and that screen has
288
+ * usually gone by the time the folder is appointed — its release has already
289
+ * fired, so a replay re-claiming under it would be held back by nobody and end
290
+ * in silence.
291
+ */
292
+ describe("the run replayed once the folder is appointed", () => {
293
+ it("states its ending, once, with the screen that started it gone", async () => {
294
+ let stillRefusing = true;
295
+ await mountApp((call) => {
296
+ if (call.path.endsWith("/config")) return CONFIG;
297
+ if (call.path.endsWith("/mailboxes")) return MAILBOXES;
298
+ if (call.path.endsWith("/messages/delete")) {
299
+ if (!stillRefusing) return { successCount: 1, failureCount: 0 };
300
+ stillRefusing = false;
301
+ return refusal();
302
+ }
303
+ return {};
304
+ }, screenFor(INBOX));
305
+
306
+ await act(async () => {
307
+ deleteOne(INBOX, "msg-1", true);
308
+ });
309
+ await settle(promptIsUp);
310
+ assert.ok(promptIsUp(), "the refusal opened the appointment prompt");
311
+
312
+ // The user closes the wizard and leaves the mailbox while the prompt is up.
313
+ // The prompt and the run's owner are above the route, so both stay.
314
+ harness?.renderApp(createElement("div", null, "somewhere else"));
315
+ assert.equal(endings.length, 0, "nothing has ended yet");
316
+
317
+ await pressConfirm();
318
+ await settle(() => endings.length > 0);
319
+
320
+ assert.equal(endings.length, 1, "the replay's ending is said exactly once");
321
+ assert.equal(endings[0]?.kind, "delete");
322
+ assert.equal(endings[0]?.outcome.done, 1);
323
+ assert.equal(endings[0]?.outcome.error, undefined);
324
+ });
325
+
326
+ it("banners a replay refused because another run holds the slot", async () => {
327
+ let stillRefusing = true;
328
+ await mountApp(
329
+ (call) => {
330
+ if (call.path.endsWith("/config")) return CONFIG;
331
+ if (call.path.endsWith("/mailboxes")) return MAILBOXES;
332
+ if (call.path.endsWith("/messages/delete")) {
333
+ const ids =
334
+ (call.body as { messageIds?: string[] } | undefined)?.messageIds ??
335
+ [];
336
+ if (ids.includes(HELD)) return new Promise<never>(() => {});
337
+ if (stillRefusing) {
338
+ stillRefusing = false;
339
+ return refusal();
340
+ }
341
+ return { successCount: 1, failureCount: 0 };
342
+ }
343
+ return {};
344
+ },
345
+ createElement("div", null, screenFor(INBOX), screenFor(ELSEWHERE)),
346
+ );
347
+
348
+ await act(async () => {
349
+ deleteOne(INBOX, "msg-1", false);
350
+ });
351
+ await settle(promptIsUp);
352
+
353
+ // Another mailbox takes the one run slot while the prompt is still open.
354
+ await act(async () => {
355
+ deleteOne(ELSEWHERE, HELD, false);
356
+ });
357
+ await settle(() => screen(ELSEWHERE).hook.isRunning);
358
+
359
+ await pressConfirm();
360
+ await settle(() => (harness?.text() ?? "").includes("still running"));
361
+
362
+ const text = harness?.text() ?? "";
363
+ assert.match(text, /Couldn't delete these messages/);
364
+ assert.match(
365
+ text,
366
+ /still running/,
367
+ "a replay with nowhere to run says so instead of vanishing",
368
+ );
369
+ });
370
+ });