@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.
- package/package.json +1 -1
- package/src/components/mail/BulkRunProvider.tsx +331 -0
- package/src/components/mail/MessageList.tsx +38 -23
- package/src/components/mail/SelectionWizardHost.tsx +57 -24
- package/src/hooks/cross-account-bulk.render.test.ts +3 -3
- package/src/hooks/escalated-run-lifetime.render.test.ts +5 -4
- package/src/hooks/escalated-run-refusal.render.test.ts +219 -5
- package/src/hooks/escalated-run-survives-navigation.render.test.ts +524 -0
- package/src/hooks/useEscalatedActions.test.ts +1 -1
- package/src/hooks/useEscalatedActions.ts +93 -187
- package/src/lib/bulk-action-copy.ts +21 -0
- package/src/lib/bulk-actions.ts +32 -0
- package/src/routes/__root.tsx +16 -11
- package/src/test-support/bulk-run.ts +13 -0
- package/src/test-support/dom.ts +6 -1
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run survives leaving the mailbox (#112).
|
|
3
|
+
*
|
|
4
|
+
* `escalated-run-lifetime.render.test.ts` pins the run outliving the selection
|
|
5
|
+
* and the wizard. This is the movement past both: the user leaves the mailbox
|
|
6
|
+
* altogether, which unmounts the list and every screen the run was reported on.
|
|
7
|
+
* Held in the surface that started it, the progress, the count and the
|
|
8
|
+
* `AbortController` all went with the route — returning showed an idle header
|
|
9
|
+
* over mail that was still being deleted, Stop reached nothing, and an ending
|
|
10
|
+
* that landed after the move was dropped in silence.
|
|
11
|
+
*
|
|
12
|
+
* The run is mounted through the real hook against the real fetch seam, and the
|
|
13
|
+
* screen is unmounted and mounted again underneath the app's own providers —
|
|
14
|
+
* which is what a route change does to it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
19
|
+
import {
|
|
20
|
+
act,
|
|
21
|
+
createElement,
|
|
22
|
+
type FunctionComponent,
|
|
23
|
+
useEffect,
|
|
24
|
+
useRef,
|
|
25
|
+
} from "react";
|
|
26
|
+
import type {
|
|
27
|
+
BulkRunOutcome,
|
|
28
|
+
BulkRunStart,
|
|
29
|
+
EscalatedAction,
|
|
30
|
+
} from "../lib/bulk-actions";
|
|
31
|
+
import { ranOutcome } from "../test-support/bulk-run";
|
|
32
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
33
|
+
import {
|
|
34
|
+
type EscalationSearchQuery,
|
|
35
|
+
type UseEscalatedActionsResult,
|
|
36
|
+
useEscalatedActions,
|
|
37
|
+
} from "./useEscalatedActions";
|
|
38
|
+
|
|
39
|
+
const INBOX = "mbx-inbox";
|
|
40
|
+
const ELSEWHERE = "mbx-archive";
|
|
41
|
+
const PAGE_SIZE = 100;
|
|
42
|
+
/** Three full pages and a short one — page boundaries to park a run at. */
|
|
43
|
+
const TOTAL = PAGE_SIZE * 3 + 12;
|
|
44
|
+
|
|
45
|
+
let harness: DomHarness | undefined;
|
|
46
|
+
let server: MailServer | undefined;
|
|
47
|
+
|
|
48
|
+
interface MailServer {
|
|
49
|
+
/** Every message id a delete call carried, in the order they were sent. */
|
|
50
|
+
deleted: () => string[];
|
|
51
|
+
/** Requests queued at the boundary rather than answered. */
|
|
52
|
+
held: () => number;
|
|
53
|
+
/** Requests the client cancelled while they were held. */
|
|
54
|
+
aborted: () => number;
|
|
55
|
+
/** Answer what is held now, and go on holding the batch after it. */
|
|
56
|
+
answerHeld: () => void;
|
|
57
|
+
/** Answer everything held, and stop holding. */
|
|
58
|
+
release: () => void;
|
|
59
|
+
restore: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const searchPage = (url: URL): unknown => {
|
|
63
|
+
const query = url.searchParams.get("query") ?? "";
|
|
64
|
+
if (url.searchParams.get("results") === "false") return { count: TOTAL };
|
|
65
|
+
const served = Number(url.searchParams.get("continuationToken") ?? "0");
|
|
66
|
+
const size = Math.min(PAGE_SIZE, Math.max(TOTAL - served, 0));
|
|
67
|
+
return {
|
|
68
|
+
items: Array.from({ length: size }, (_, i) => ({
|
|
69
|
+
messageId: `${query}-${served + i}`,
|
|
70
|
+
})),
|
|
71
|
+
continuationToken:
|
|
72
|
+
served + size < TOTAL ? String(served + size) : undefined,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Answers the search and the bulk delete, holding every delete after the first
|
|
78
|
+
* so a run can be caught mid-flight with real progress behind it. A held
|
|
79
|
+
* request honours its own abort signal, the way the platform's `fetch` does.
|
|
80
|
+
*/
|
|
81
|
+
const startMailServer = (): MailServer => {
|
|
82
|
+
const original = globalThis.fetch;
|
|
83
|
+
const deleted: string[] = [];
|
|
84
|
+
let waiting: Array<() => void> = [];
|
|
85
|
+
let holding = true;
|
|
86
|
+
let served = 0;
|
|
87
|
+
let cancelled = 0;
|
|
88
|
+
|
|
89
|
+
const hold = (signal: AbortSignal | undefined): Promise<void> =>
|
|
90
|
+
new Promise<void>((resolve, reject) => {
|
|
91
|
+
const fail = () => {
|
|
92
|
+
cancelled += 1;
|
|
93
|
+
waiting = waiting.filter((held) => held !== resolve);
|
|
94
|
+
reject(signal?.reason);
|
|
95
|
+
};
|
|
96
|
+
if (signal?.aborted) {
|
|
97
|
+
fail();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
signal?.addEventListener("abort", fail, { once: true });
|
|
101
|
+
waiting.push(resolve);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
105
|
+
const request = input instanceof Request ? input : undefined;
|
|
106
|
+
const raw = request ? await request.clone().text() : undefined;
|
|
107
|
+
const url = new URL(request ? request.url : String(input), "http://x");
|
|
108
|
+
|
|
109
|
+
if (url.pathname.endsWith("/threads/search")) {
|
|
110
|
+
return Response.json(searchPage(url));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const body = raw
|
|
114
|
+
? (JSON.parse(raw) as { messageIds: string[] })
|
|
115
|
+
: undefined;
|
|
116
|
+
served += 1;
|
|
117
|
+
if (holding && served > 1) {
|
|
118
|
+
await hold(request?.signal);
|
|
119
|
+
}
|
|
120
|
+
deleted.push(...(body?.messageIds ?? []));
|
|
121
|
+
return Response.json({ successCount: 0, failureCount: 0 });
|
|
122
|
+
}) as typeof globalThis.fetch;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
deleted: () => deleted,
|
|
126
|
+
held: () => waiting.length,
|
|
127
|
+
aborted: () => cancelled,
|
|
128
|
+
answerHeld: () => {
|
|
129
|
+
const queued = waiting;
|
|
130
|
+
waiting = [];
|
|
131
|
+
for (const resolve of queued) resolve();
|
|
132
|
+
},
|
|
133
|
+
release: () => {
|
|
134
|
+
holding = false;
|
|
135
|
+
const queued = waiting;
|
|
136
|
+
waiting = [];
|
|
137
|
+
for (const resolve of queued) resolve();
|
|
138
|
+
},
|
|
139
|
+
restore: () => {
|
|
140
|
+
globalThis.fetch = original;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Endings the run's owner stated once no screen was left to state them. */
|
|
146
|
+
let endings: Array<{
|
|
147
|
+
kind: EscalatedAction["kind"];
|
|
148
|
+
matched: number;
|
|
149
|
+
outcome: BulkRunOutcome;
|
|
150
|
+
}> = [];
|
|
151
|
+
|
|
152
|
+
const SEARCH: EscalationSearchQuery = { query: "npm" };
|
|
153
|
+
|
|
154
|
+
interface ScreenSpec {
|
|
155
|
+
mailboxId: string;
|
|
156
|
+
/** The mailbox in the user's words, which a refusal names. */
|
|
157
|
+
label?: string;
|
|
158
|
+
/**
|
|
159
|
+
* The screen states the ending of a run it starts, the way the wizard's run
|
|
160
|
+
* screen does — so the owner holds that run's banner back while it is up.
|
|
161
|
+
*/
|
|
162
|
+
reportsInPlace?: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface MountedScreen {
|
|
166
|
+
hook: UseEscalatedActionsResult;
|
|
167
|
+
/** Passed to the run this screen starts, when it reports in place. */
|
|
168
|
+
claimEnding: ((release: () => void) => void) | undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const screens = new Map<string, MountedScreen>();
|
|
172
|
+
/**
|
|
173
|
+
* One component type per screen, so re-rendering the same set of mailboxes is a
|
|
174
|
+
* re-render rather than a remount — a fresh type would unmount the screen the
|
|
175
|
+
* test is holding a run open on.
|
|
176
|
+
*/
|
|
177
|
+
const probes = new Map<string, FunctionComponent>();
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The mailbox screen, mounted and unmounted the way a route change does it.
|
|
181
|
+
* The providers above it stay mounted throughout — they are the app shell, not
|
|
182
|
+
* the route.
|
|
183
|
+
*/
|
|
184
|
+
const mailboxScreen = ({ mailboxId, label, reportsInPlace }: ScreenSpec) => {
|
|
185
|
+
const key = `${mailboxId}|${reportsInPlace ? "in-place" : "bannered"}`;
|
|
186
|
+
const held = probes.get(key);
|
|
187
|
+
if (held) return createElement(held, { key });
|
|
188
|
+
const Probe = () => {
|
|
189
|
+
const release = useRef<(() => void) | undefined>(undefined);
|
|
190
|
+
useEffect(
|
|
191
|
+
() => () => {
|
|
192
|
+
release.current?.();
|
|
193
|
+
release.current = undefined;
|
|
194
|
+
screens.delete(mailboxId);
|
|
195
|
+
},
|
|
196
|
+
[],
|
|
197
|
+
);
|
|
198
|
+
const hook = useEscalatedActions({
|
|
199
|
+
mailboxId,
|
|
200
|
+
mailboxLabel: label,
|
|
201
|
+
enabled: true,
|
|
202
|
+
predicateKey: `${mailboxId}|npm`,
|
|
203
|
+
searchQuery: SEARCH,
|
|
204
|
+
reportEnding: (kind, matched, outcome) => {
|
|
205
|
+
endings.push({ kind, matched, outcome });
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
screens.set(mailboxId, {
|
|
209
|
+
hook,
|
|
210
|
+
claimEnding: reportsInPlace
|
|
211
|
+
? (next) => {
|
|
212
|
+
release.current?.();
|
|
213
|
+
release.current = next;
|
|
214
|
+
}
|
|
215
|
+
: undefined,
|
|
216
|
+
});
|
|
217
|
+
return null;
|
|
218
|
+
};
|
|
219
|
+
probes.set(key, Probe);
|
|
220
|
+
return createElement(Probe, { key });
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const screen = (mailboxId = INBOX): MountedScreen => {
|
|
224
|
+
const mounted = screens.get(mailboxId);
|
|
225
|
+
if (!mounted) throw new Error(`no screen is mounted for ${mailboxId}`);
|
|
226
|
+
return mounted;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const hook = (mailboxId = INBOX): UseEscalatedActionsResult =>
|
|
230
|
+
screen(mailboxId).hook;
|
|
231
|
+
|
|
232
|
+
/** Leave the mailbox: the screen goes, the app shell stays. */
|
|
233
|
+
const leaveMailbox = (): void => {
|
|
234
|
+
harness?.renderApp(createElement("div", null, "somewhere else"));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const openMailbox = (...specs: ScreenSpec[]): void => {
|
|
238
|
+
const mounted = specs.length > 0 ? specs : [{ mailboxId: INBOX }];
|
|
239
|
+
harness?.renderApp(createElement("div", null, ...mounted.map(mailboxScreen)));
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** A press: the state it sets belongs to the same commit React would batch. */
|
|
243
|
+
const press = <T>(action: () => T): T => {
|
|
244
|
+
let result: T | undefined;
|
|
245
|
+
act(() => {
|
|
246
|
+
result = action();
|
|
247
|
+
});
|
|
248
|
+
return result as T;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/** Enough turns for a sequential page-and-apply run to reach its end. */
|
|
252
|
+
const settle = async (): Promise<void> => {
|
|
253
|
+
if (!harness) throw new Error("nothing mounted");
|
|
254
|
+
for (let round = 0; round < 40; round += 1) await harness.flush();
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/** Park the run at the page boundary the second batch is waiting on. */
|
|
258
|
+
const parkAtBoundary = async (): Promise<void> => {
|
|
259
|
+
if (!harness || !server) throw new Error("nothing mounted");
|
|
260
|
+
for (let round = 0; round < 20; round += 1) {
|
|
261
|
+
if (server.held() > 0) return;
|
|
262
|
+
await harness.flush();
|
|
263
|
+
}
|
|
264
|
+
throw new Error("the run never reached a page boundary");
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Escalate to the whole match and start deleting it, parked at the page
|
|
269
|
+
* boundary the second batch is waiting on. The run is handed back wrapped: a
|
|
270
|
+
* bare promise would be unwrapped by the caller's own `await`, which would wait
|
|
271
|
+
* out the very run the test is about to walk away from.
|
|
272
|
+
*/
|
|
273
|
+
const startRun = async (
|
|
274
|
+
mailboxId = INBOX,
|
|
275
|
+
): Promise<{ run: Promise<BulkRunStart> }> => {
|
|
276
|
+
press(() => hook(mailboxId).escalate());
|
|
277
|
+
await settle();
|
|
278
|
+
assert.deepEqual(hook(mailboxId).phase, { kind: "escalated", total: TOTAL });
|
|
279
|
+
const run = press(() =>
|
|
280
|
+
hook(mailboxId).runAction(
|
|
281
|
+
{ kind: "delete" },
|
|
282
|
+
undefined,
|
|
283
|
+
screen(mailboxId).claimEnding,
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
await parkAtBoundary();
|
|
287
|
+
return { run };
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
beforeEach(() => {
|
|
291
|
+
server = startMailServer();
|
|
292
|
+
endings = [];
|
|
293
|
+
screens.clear();
|
|
294
|
+
probes.clear();
|
|
295
|
+
harness = createDomHarness();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
afterEach(() => {
|
|
299
|
+
harness?.close();
|
|
300
|
+
harness = undefined;
|
|
301
|
+
server?.release();
|
|
302
|
+
server?.restore();
|
|
303
|
+
server = undefined;
|
|
304
|
+
screens.clear();
|
|
305
|
+
probes.clear();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("a run the user leaves the mailbox during", () => {
|
|
309
|
+
it("is still reporting live progress when they come back", async () => {
|
|
310
|
+
openMailbox();
|
|
311
|
+
const { run } = await startRun();
|
|
312
|
+
const doneBeforeLeaving = hook().progress?.done ?? 0;
|
|
313
|
+
assert.ok(doneBeforeLeaving > 0, "the run had covered a page");
|
|
314
|
+
|
|
315
|
+
leaveMailbox();
|
|
316
|
+
// A page lands while no screen is mounted at all: the run is paging on its
|
|
317
|
+
// own, which is the whole point of it having an owner above the route.
|
|
318
|
+
press(() => server?.answerHeld());
|
|
319
|
+
await parkAtBoundary();
|
|
320
|
+
openMailbox();
|
|
321
|
+
|
|
322
|
+
assert.equal(hook().isRunning, true, "the run came back with the mailbox");
|
|
323
|
+
assert.deepEqual(hook().runningAction, { kind: "delete" });
|
|
324
|
+
assert.equal(hook().progress?.total, TOTAL);
|
|
325
|
+
assert.ok(
|
|
326
|
+
(hook().progress?.done ?? 0) > doneBeforeLeaving,
|
|
327
|
+
"progress kept counting while nothing was showing it",
|
|
328
|
+
);
|
|
329
|
+
// The selection did not survive, and must not: it is the screen's, and the
|
|
330
|
+
// user has left it. The run is what the bar is now about.
|
|
331
|
+
assert.deepEqual(hook().phase, { kind: "idle" });
|
|
332
|
+
|
|
333
|
+
press(() => server?.release());
|
|
334
|
+
await settle();
|
|
335
|
+
await run;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("is stoppable from the mailbox they came back to", async () => {
|
|
339
|
+
openMailbox();
|
|
340
|
+
const { run } = await startRun();
|
|
341
|
+
|
|
342
|
+
leaveMailbox();
|
|
343
|
+
openMailbox();
|
|
344
|
+
const sentBeforeStop = server?.deleted().length ?? 0;
|
|
345
|
+
press(() => hook().stop());
|
|
346
|
+
press(() => server?.release());
|
|
347
|
+
await settle();
|
|
348
|
+
const outcome = ranOutcome(await run);
|
|
349
|
+
|
|
350
|
+
assert.equal(server?.aborted(), 1, "the delete in flight was cancelled");
|
|
351
|
+
assert.equal(
|
|
352
|
+
server?.deleted().length,
|
|
353
|
+
sentBeforeStop,
|
|
354
|
+
"no further ids reached the server",
|
|
355
|
+
);
|
|
356
|
+
assert.equal(outcome.cancelled, true);
|
|
357
|
+
assert.equal(
|
|
358
|
+
outcome.error,
|
|
359
|
+
undefined,
|
|
360
|
+
"a stop the user pressed is not a failure to report",
|
|
361
|
+
);
|
|
362
|
+
assert.equal(hook().isRunning, false);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("states how it ended even though nothing was left showing it", async () => {
|
|
366
|
+
openMailbox();
|
|
367
|
+
const { run } = await startRun();
|
|
368
|
+
|
|
369
|
+
leaveMailbox();
|
|
370
|
+
press(() => server?.release());
|
|
371
|
+
await settle();
|
|
372
|
+
const outcome = ranOutcome(await run);
|
|
373
|
+
|
|
374
|
+
assert.equal(outcome.done, TOTAL);
|
|
375
|
+
assert.deepEqual(endings, [
|
|
376
|
+
{ kind: "delete", matched: TOTAL, outcome },
|
|
377
|
+
// One ending, said once: a run that finishes with nobody watching is
|
|
378
|
+
// never announced twice and never dropped.
|
|
379
|
+
]);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe("another mailbox, while a run is going", () => {
|
|
384
|
+
it("shows nothing of a run that is not its own", async () => {
|
|
385
|
+
openMailbox();
|
|
386
|
+
const { run } = await startRun();
|
|
387
|
+
|
|
388
|
+
leaveMailbox();
|
|
389
|
+
openMailbox({ mailboxId: ELSEWHERE });
|
|
390
|
+
|
|
391
|
+
assert.equal(
|
|
392
|
+
hook(ELSEWHERE).isRunning,
|
|
393
|
+
false,
|
|
394
|
+
"the run belongs to the inbox",
|
|
395
|
+
);
|
|
396
|
+
assert.equal(hook(ELSEWHERE).progress, undefined);
|
|
397
|
+
|
|
398
|
+
press(() => server?.release());
|
|
399
|
+
await settle();
|
|
400
|
+
await run;
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// The Stop on a "Select all N matching" count used to reach the provider
|
|
404
|
+
// unconditionally, so cancelling a count in one mailbox ended a delete going
|
|
405
|
+
// in another — with no banner, because a stop is not a failure.
|
|
406
|
+
it("cancels its own count without touching the run", async () => {
|
|
407
|
+
openMailbox({ mailboxId: INBOX }, { mailboxId: ELSEWHERE });
|
|
408
|
+
const { run } = await startRun();
|
|
409
|
+
|
|
410
|
+
press(() => hook(ELSEWHERE).escalate());
|
|
411
|
+
await settle();
|
|
412
|
+
press(() => hook(ELSEWHERE).stop());
|
|
413
|
+
await settle();
|
|
414
|
+
|
|
415
|
+
assert.equal(server?.aborted(), 0, "no delete was cancelled");
|
|
416
|
+
assert.equal(hook(INBOX).isRunning, true, "the inbox run is still going");
|
|
417
|
+
|
|
418
|
+
press(() => server?.release());
|
|
419
|
+
await settle();
|
|
420
|
+
const outcome = ranOutcome(await run);
|
|
421
|
+
|
|
422
|
+
assert.equal(outcome.cancelled, false);
|
|
423
|
+
assert.equal(outcome.done, TOTAL, "the run covered the whole match");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("is refused a run of its own while the first one is going", async () => {
|
|
427
|
+
openMailbox({ mailboxId: INBOX, label: "Inbox" }, { mailboxId: ELSEWHERE });
|
|
428
|
+
const { run } = await startRun();
|
|
429
|
+
const sentBeforeSecond = server?.deleted().length ?? 0;
|
|
430
|
+
|
|
431
|
+
press(() => hook(ELSEWHERE).escalate());
|
|
432
|
+
await settle();
|
|
433
|
+
const second = await press(() =>
|
|
434
|
+
hook(ELSEWHERE).runAction({ kind: "delete" }),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
assert.equal(second.kind, "refused");
|
|
438
|
+
if (second.kind === "refused") {
|
|
439
|
+
assert.ok(
|
|
440
|
+
second.reason.includes("Inbox"),
|
|
441
|
+
`the refusal names the run in flight: ${second.reason}`,
|
|
442
|
+
);
|
|
443
|
+
assert.ok(second.reason.includes(String(TOTAL)));
|
|
444
|
+
}
|
|
445
|
+
assert.equal(
|
|
446
|
+
server?.deleted().length,
|
|
447
|
+
sentBeforeSecond,
|
|
448
|
+
"the refused commit sent nothing",
|
|
449
|
+
);
|
|
450
|
+
// The selection it was refused for stands, so stopping the other run is
|
|
451
|
+
// enough to answer it.
|
|
452
|
+
assert.deepEqual(hook(ELSEWHERE).phase, {
|
|
453
|
+
kind: "escalated",
|
|
454
|
+
total: TOTAL,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// The first run is untouched by the refusal, and still the one Stop ends.
|
|
458
|
+
assert.equal(hook(INBOX).isRunning, true);
|
|
459
|
+
press(() => hook(INBOX).stop());
|
|
460
|
+
press(() => server?.release());
|
|
461
|
+
await settle();
|
|
462
|
+
const outcome = ranOutcome(await run);
|
|
463
|
+
|
|
464
|
+
assert.equal(outcome.cancelled, true);
|
|
465
|
+
assert.equal(hook(INBOX).isRunning, false);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe("the claim a screen showing an ending in place holds", () => {
|
|
470
|
+
// An organize or filter commit reports in place too, and used to take a claim
|
|
471
|
+
// over any ending at all — so the delete still paging in another mailbox
|
|
472
|
+
// finished in silence.
|
|
473
|
+
it("does not swallow the ending of the run that is going", async () => {
|
|
474
|
+
openMailbox(
|
|
475
|
+
{ mailboxId: INBOX, reportsInPlace: true },
|
|
476
|
+
{ mailboxId: ELSEWHERE, reportsInPlace: true },
|
|
477
|
+
);
|
|
478
|
+
const { run } = await startRun();
|
|
479
|
+
|
|
480
|
+
// The run's own screen goes; the one showing an organize commit stays.
|
|
481
|
+
openMailbox({ mailboxId: ELSEWHERE, reportsInPlace: true });
|
|
482
|
+
press(() => server?.release());
|
|
483
|
+
await settle();
|
|
484
|
+
const outcome = ranOutcome(await run);
|
|
485
|
+
|
|
486
|
+
assert.equal(outcome.done, TOTAL);
|
|
487
|
+
assert.deepEqual(endings, [{ kind: "delete", matched: TOTAL, outcome }]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("holds the ending back while the screen showing it is up", async () => {
|
|
491
|
+
openMailbox({ mailboxId: INBOX, reportsInPlace: true });
|
|
492
|
+
const { run } = await startRun();
|
|
493
|
+
|
|
494
|
+
press(() => server?.release());
|
|
495
|
+
await settle();
|
|
496
|
+
await run;
|
|
497
|
+
|
|
498
|
+
assert.deepEqual(endings, [], "the screen states it in place");
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe("a run the session ends under", () => {
|
|
503
|
+
// The whole app tree, this provider included, comes down with the session at
|
|
504
|
+
// the auth gate. Nothing there ended the run, so it went on deleting mail for
|
|
505
|
+
// an account nobody was signed in to any more.
|
|
506
|
+
it("stops when the app is torn down", async () => {
|
|
507
|
+
openMailbox();
|
|
508
|
+
const { run } = await startRun();
|
|
509
|
+
const sentBeforeSignOut = server?.deleted().length ?? 0;
|
|
510
|
+
|
|
511
|
+
harness?.unmount();
|
|
512
|
+
press(() => server?.release());
|
|
513
|
+
await settle();
|
|
514
|
+
const outcome = ranOutcome(await run);
|
|
515
|
+
|
|
516
|
+
assert.equal(server?.aborted(), 1, "the delete in flight was cancelled");
|
|
517
|
+
assert.equal(
|
|
518
|
+
server?.deleted().length,
|
|
519
|
+
sentBeforeSignOut,
|
|
520
|
+
"no further ids reached the server",
|
|
521
|
+
);
|
|
522
|
+
assert.equal(outcome.cancelled, true);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
@@ -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", () => {
|