@remit/web-client 0.0.269 → 0.0.271

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.269",
3
+ "version": "0.0.271",
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": {
@@ -63,7 +63,7 @@ describe("reporting how a run ended", () => {
63
63
  );
64
64
  assert.equal(stopped?.title, "Stopped after 100");
65
65
  assert.match(stopped?.detail ?? "", /100 of 3,000 moved to Trash\./);
66
- assert.match(stopped?.detail ?? "", /Nothing was sent for the rest/);
66
+ assert.match(stopped?.detail ?? "", /The rest were not sent/);
67
67
 
68
68
  const covered = runEndingBanner("delete", 100, ended(), "trash");
69
69
  assert.match(covered?.title ?? "", /^100 moved to Trash\./);
@@ -75,6 +75,7 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
75
75
  import { useSearchConversion } from "@/lib/search-conversion";
76
76
  import type { WizardSelectionMessage } from "@/lib/wizard-selection";
77
77
  import { useWizardEntryValue, useWizardStep } from "@/routing";
78
+ import { bulkRunReport } from "./bulk-run-state";
78
79
  import { organizeRunState } from "./organize-run-state";
79
80
  import {
80
81
  retryIntent,
@@ -823,57 +824,21 @@ function SelectionWizardSession({
823
824
 
824
825
  const bulkSnapshot = useCallback((): RunSnapshot => {
825
826
  if (!bulkRun) return NOT_STARTED;
826
- const { matched, outcome, failureReason } = bulkRun;
827
- // Nothing was sent, and nothing about sending it again resolves what was
828
- // missing — so the screen carries why rather than the generic ending (#522).
829
- if (failureReason !== undefined) {
830
- return { ...NOT_STARTED, state: "commitFailed", failureReason };
831
- }
832
- if (!outcome) {
833
- const applied = runProgress?.done ?? 0;
834
- // A predicate matches more by the time the run re-pages it than the count
835
- // saw, so what it has covered can overtake what it was offered against.
836
- // The bar never reads more done than out of, and neither does this.
837
- return {
838
- state: "backApplyRunning",
839
- matched: Math.max(matched, applied),
840
- applied,
841
- failed: 0,
842
- failures: [],
843
- };
844
- }
845
- const stopped = outcome.cancelled || outcome.error !== undefined;
846
- if (!stopped) {
847
- return {
848
- state: "backApplyComplete",
849
- matched: Math.max(matched, outcome.done),
850
- applied: outcome.done,
851
- failed: 0,
852
- failures: [],
853
- };
854
- }
855
- if (outcome.done === 0) {
856
- return { ...NOT_STARTED, state: "commitFailed" };
857
- }
858
- // The run stopped part-way. Nothing here was rejected: a returned bulk call
859
- // accepts every id in it, so the only failure this layer sees is a call that
860
- // threw, and everything after it was never sent. A bounded run hands back
861
- // exactly those ids; a predicate run re-resolves its match on every pass and
862
- // has no remainder to hand back, so what is left is the difference between
863
- // the count and what the run covered.
864
- const failures = outcome.failedIds
865
- .map((id) => rowsById.get(id))
866
- .filter((message): message is WizardMessage => message !== undefined);
867
- const unreached =
868
- outcome.failedIds.length > 0
869
- ? outcome.failedIds.length
870
- : Math.max(matched - outcome.done, 1);
827
+ const report = bulkRunReport({
828
+ matched: bulkRun.matched,
829
+ outcome: bulkRun.outcome,
830
+ failureReason: bulkRun.failureReason,
831
+ progressDone: runProgress?.done ?? 0,
832
+ });
871
833
  return {
872
- state: "runStopped",
873
- matched: Math.max(matched, outcome.done),
874
- applied: outcome.done,
875
- failed: unreached,
876
- failures,
834
+ state: report.state,
835
+ matched: report.matched,
836
+ applied: report.applied,
837
+ failed: report.failed,
838
+ failures: report.failedIds
839
+ .map((id) => rowsById.get(id))
840
+ .filter((message): message is WizardMessage => message !== undefined),
841
+ failureReason: report.failureReason,
877
842
  };
878
843
  }, [bulkRun, runProgress, rowsById]);
879
844
 
@@ -0,0 +1,100 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { BulkRunOutcome } from "@/lib/bulk-actions";
4
+ import { bulkRunReport } from "./bulk-run-state";
5
+
6
+ const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
7
+ done: 0,
8
+ failedIds: [],
9
+ cancelled: false,
10
+ ...over,
11
+ });
12
+
13
+ const report = (
14
+ over: Partial<Parameters<typeof bulkRunReport>[0]> = {},
15
+ ): ReturnType<typeof bulkRunReport> =>
16
+ bulkRunReport({
17
+ matched: 100,
18
+ outcome: undefined,
19
+ failureReason: undefined,
20
+ progressDone: 0,
21
+ ...over,
22
+ });
23
+
24
+ describe("what the run screen says a bulk run did", () => {
25
+ it("shows a run still going against what it was started with", () => {
26
+ assert.deepEqual(report({ progressDone: 40 }), {
27
+ state: "backApplyRunning",
28
+ matched: 100,
29
+ applied: 40,
30
+ failed: 0,
31
+ failedIds: [],
32
+ });
33
+ });
34
+
35
+ it("never reads more done than out of", () => {
36
+ const going = report({ matched: 1284, progressDone: 1340 });
37
+ assert.equal(going.matched, 1340);
38
+ });
39
+
40
+ it("ends a run that covered its match on the success screen", () => {
41
+ const covered = report({ outcome: ended({ done: 100 }) });
42
+ assert.equal(covered.state, "backApplyComplete");
43
+ assert.equal(covered.applied, 100);
44
+ });
45
+
46
+ it("carries a commit that never started, and why", () => {
47
+ const never = report({ failureReason: "There is no Junk folder" });
48
+ assert.equal(never.state, "commitFailed");
49
+ assert.equal(never.failureReason, "There is no Junk folder");
50
+ assert.equal(never.matched, 0);
51
+ });
52
+
53
+ it("says a run that errored before anything landed never started", () => {
54
+ const threw = report({ outcome: ended({ error: new Error("500") }) });
55
+ assert.equal(threw.state, "commitFailed");
56
+ });
57
+ });
58
+
59
+ /**
60
+ * Regression for #113. A stop used to be read only between pages, so it always
61
+ * landed with at least one batch already applied and `done === 0` could only
62
+ * mean a failure. Aborting the request in flight makes zero the ordinary
63
+ * outcome of stopping during the first batch — the only batch a selection of a
64
+ * hundred or fewer ever has — and answering that with the danger screen told
65
+ * the user nothing had changed while the server had most likely taken all of
66
+ * them.
67
+ */
68
+ describe("a run the user stopped", () => {
69
+ it("reports a stop during the first batch as stopped, not as a failed start", () => {
70
+ const stopped = report({
71
+ matched: 100,
72
+ outcome: ended({ cancelled: true, failedIds: Array(100).fill("m") }),
73
+ });
74
+ assert.equal(stopped.state, "runStopped");
75
+ assert.notEqual(stopped.state, "commitFailed");
76
+ assert.equal(stopped.applied, 0);
77
+ assert.equal(stopped.failed, 100);
78
+ });
79
+
80
+ it("reports a stop that reached nothing at all as stopped too", () => {
81
+ // The escalated path hands back no remainder: the predicate re-resolves on
82
+ // every run, so there are no ids to list, only a difference to name.
83
+ const stopped = report({
84
+ matched: 3000,
85
+ outcome: ended({ cancelled: true }),
86
+ });
87
+ assert.equal(stopped.state, "runStopped");
88
+ assert.equal(stopped.failed, 3000);
89
+ });
90
+
91
+ it("still reports a stop that got part-way", () => {
92
+ const stopped = report({
93
+ matched: 3000,
94
+ outcome: ended({ done: 1200, cancelled: true }),
95
+ });
96
+ assert.equal(stopped.state, "runStopped");
97
+ assert.equal(stopped.applied, 1200);
98
+ assert.equal(stopped.failed, 1800);
99
+ });
100
+ });
@@ -0,0 +1,99 @@
1
+ import type { RunState } from "@remit/ui";
2
+ import type { BulkRunOutcome } from "@/lib/bulk-actions";
3
+
4
+ /** What the run screen knows about a chunked bulk run it is reporting on. */
5
+ export interface BulkRunReading {
6
+ /** The count the run was started against. */
7
+ matched: number;
8
+ /** Set once the run has ended, for any reason. */
9
+ outcome: BulkRunOutcome | undefined;
10
+ /**
11
+ * Why the commit never started, when the reason is one the same commit cannot
12
+ * get past (#522).
13
+ */
14
+ failureReason: string | undefined;
15
+ /** Ids covered so far, read off the progress bar while the run is going. */
16
+ progressDone: number;
17
+ }
18
+
19
+ export interface BulkRunReport {
20
+ state: RunState;
21
+ matched: number;
22
+ applied: number;
23
+ failed: number;
24
+ /** Ids the run leaves unconfirmed; the screen resolves them to rows. */
25
+ failedIds: readonly string[];
26
+ failureReason?: string;
27
+ }
28
+
29
+ const NOTHING_STARTED = {
30
+ matched: 0,
31
+ applied: 0,
32
+ failed: 0,
33
+ failedIds: [] as readonly string[],
34
+ };
35
+
36
+ /**
37
+ * What the run screen says a bulk run did.
38
+ *
39
+ * Stopping is read before counting, because a stop is not a failure however
40
+ * little it reached (#113). Pressing Stop during the first batch — the only
41
+ * batch a selection of 100 or fewer has — leaves `done` at zero while the
42
+ * server has most likely applied all of them, and answering that with
43
+ * "Couldn't start deleting / Nothing has changed" is both alarming and wrong.
44
+ * Only a run that errored before anything landed has nothing to report but the
45
+ * failure.
46
+ */
47
+ export const bulkRunReport = ({
48
+ matched,
49
+ outcome,
50
+ failureReason,
51
+ progressDone,
52
+ }: BulkRunReading): BulkRunReport => {
53
+ // Nothing was sent, and nothing about sending it again resolves what was
54
+ // missing — so the screen carries why rather than the generic ending (#522).
55
+ if (failureReason !== undefined) {
56
+ return { ...NOTHING_STARTED, state: "commitFailed", failureReason };
57
+ }
58
+ if (!outcome) {
59
+ // A predicate matches more by the time the run re-pages it than the count
60
+ // saw, so what it has covered can overtake what it was offered against.
61
+ // The bar never reads more done than out of, and neither does this.
62
+ return {
63
+ state: "backApplyRunning",
64
+ matched: Math.max(matched, progressDone),
65
+ applied: progressDone,
66
+ failed: 0,
67
+ failedIds: [],
68
+ };
69
+ }
70
+ if (!outcome.cancelled && outcome.error === undefined) {
71
+ return {
72
+ state: "backApplyComplete",
73
+ matched: Math.max(matched, outcome.done),
74
+ applied: outcome.done,
75
+ failed: 0,
76
+ failedIds: [],
77
+ };
78
+ }
79
+ if (!outcome.cancelled && outcome.done === 0) {
80
+ return { ...NOTHING_STARTED, state: "commitFailed" };
81
+ }
82
+ // The run stopped part-way. Nothing here was rejected: a returned bulk call
83
+ // accepts every id in it, so the only failure this layer sees is a call that
84
+ // threw or was aborted, and everything after it was never sent. A bounded run
85
+ // hands back exactly those ids; a predicate run re-resolves its match on every
86
+ // pass and has no remainder to hand back, so what is left is the difference
87
+ // between the count and what the run covered.
88
+ const unreached =
89
+ outcome.failedIds.length > 0
90
+ ? outcome.failedIds.length
91
+ : Math.max(matched - outcome.done, 1);
92
+ return {
93
+ state: "runStopped",
94
+ matched: Math.max(matched, outcome.done),
95
+ applied: outcome.done,
96
+ failed: unreached,
97
+ failedIds: outcome.failedIds,
98
+ };
99
+ };
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * These mount the real hook against the real fetch seam and count the batches
8
8
  * that actually left, so what is pinned is the run's own lifetime rather than
9
- * the wording of whichever screen started it. Every run is parked at a page
10
- * boundary before the test acts on it: that is where the run reads whether it
11
- * has been told to stop, and so the only place the difference shows.
9
+ * the wording of whichever screen started it. Every run is parked on a request
10
+ * the server is holding before the test acts on it: that is where leaving the
11
+ * selection and stopping the run tell each other apart.
12
12
  */
13
13
 
14
14
  import assert from "node:assert/strict";
@@ -32,8 +32,12 @@ let server: MailServer | undefined;
32
32
  interface MailServer {
33
33
  /** Every message id a delete call carried, in the order they were sent. */
34
34
  deleted: () => string[];
35
- /** Deletes queued at the boundary rather than answered. */
35
+ /** Requests queued at the boundary rather than answered. */
36
36
  held: () => number;
37
+ /** Requests the client cancelled while they were held. */
38
+ aborted: () => number;
39
+ /** Hold the count request too, so a stop can be pressed during counting. */
40
+ holdCounts: (on: boolean) => void;
37
41
  /** Answer everything held, and stop holding. */
38
42
  release: () => void;
39
43
  restore: () => void;
@@ -61,14 +65,33 @@ const searchPage = (url: URL): unknown => {
61
65
 
62
66
  /**
63
67
  * Answers the search and the bulk delete, holding every delete after the first
64
- * so a run can be caught mid-flight with real progress behind it.
68
+ * so a run can be caught mid-flight with real progress behind it. A held
69
+ * request honours its own abort signal, the way the platform's `fetch` does —
70
+ * that is the difference a stop is now supposed to make (#113).
65
71
  */
66
72
  const startMailServer = (): MailServer => {
67
73
  const original = globalThis.fetch;
68
74
  const deleted: string[] = [];
69
75
  let waiting: Array<() => void> = [];
70
76
  let holding = true;
77
+ let holdingCounts = false;
71
78
  let served = 0;
79
+ let cancelled = 0;
80
+
81
+ const hold = (signal: AbortSignal | undefined): Promise<void> =>
82
+ new Promise<void>((resolve, reject) => {
83
+ const fail = () => {
84
+ cancelled += 1;
85
+ waiting = waiting.filter((held) => held !== resolve);
86
+ reject(signal?.reason);
87
+ };
88
+ if (signal?.aborted) {
89
+ fail();
90
+ return;
91
+ }
92
+ signal?.addEventListener("abort", fail, { once: true });
93
+ waiting.push(resolve);
94
+ });
72
95
 
73
96
  globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
74
97
  const request = input instanceof Request ? input : undefined;
@@ -76,6 +99,9 @@ const startMailServer = (): MailServer => {
76
99
  const url = new URL(request ? request.url : String(input), "http://x");
77
100
 
78
101
  if (url.pathname.endsWith("/threads/search")) {
102
+ if (holdingCounts && url.searchParams.get("results") === "false") {
103
+ await hold(request?.signal);
104
+ }
79
105
  return Response.json(searchPage(url));
80
106
  }
81
107
 
@@ -84,7 +110,7 @@ const startMailServer = (): MailServer => {
84
110
  : undefined;
85
111
  served += 1;
86
112
  if (holding && served > 1) {
87
- await new Promise<void>((resolve) => waiting.push(resolve));
113
+ await hold(request?.signal);
88
114
  }
89
115
  deleted.push(...(body?.messageIds ?? []));
90
116
  return Response.json({ successCount: 0, failureCount: 0 });
@@ -93,8 +119,13 @@ const startMailServer = (): MailServer => {
93
119
  return {
94
120
  deleted: () => deleted,
95
121
  held: () => waiting.length,
122
+ aborted: () => cancelled,
123
+ holdCounts: (on) => {
124
+ holdingCounts = on;
125
+ },
96
126
  release: () => {
97
127
  holding = false;
128
+ holdingCounts = false;
98
129
  const queued = waiting;
99
130
  waiting = [];
100
131
  for (const resolve of queued) resolve();
@@ -235,6 +266,56 @@ describe("an escalated run the user walks away from", () => {
235
266
  });
236
267
  });
237
268
 
269
+ // Regression for #113: a stop used to be read only between pages, so the delete
270
+ // already on the wire ran to completion and its whole page — up to a hundred
271
+ // messages — was deleted after the press.
272
+ describe("stopping a run with a delete already on the wire", () => {
273
+ it("cancels that delete and sends nothing more", async () => {
274
+ const { hook } = mountEscalation({ query: "npm" });
275
+ await escalateTo(hook);
276
+
277
+ const run = press(() => hook().runAction({ kind: "delete" }));
278
+ await parkAtBoundary();
279
+ const sentBeforeStop = server?.deleted().length ?? 0;
280
+ press(() => hook().stop());
281
+ press(() => server?.release());
282
+ await settle();
283
+ const outcome = await run;
284
+
285
+ assert.equal(server?.aborted(), 1, "the delete in flight was cancelled");
286
+ assert.equal(
287
+ server?.deleted().length,
288
+ sentBeforeStop,
289
+ "no further ids reached the server",
290
+ );
291
+ assert.equal(outcome.cancelled, true);
292
+ assert.equal(
293
+ outcome.error,
294
+ undefined,
295
+ "a stop the user pressed is not a failure to report",
296
+ );
297
+ assert.equal(outcome.done, sentBeforeStop);
298
+ });
299
+
300
+ it("cancels a count in flight without raising an error", async () => {
301
+ server?.holdCounts(true);
302
+ const { hook } = mountEscalation({ query: "npm" });
303
+
304
+ press(() => hook().escalate());
305
+ await parkAtBoundary();
306
+ press(() => hook().stop());
307
+ press(() => server?.release());
308
+ await settle();
309
+
310
+ assert.equal(server?.aborted(), 1);
311
+ assert.deepEqual(hook().phase, { kind: "idle" });
312
+ assert.equal(
313
+ harness?.text().includes("Couldn't count matching messages"),
314
+ false,
315
+ );
316
+ });
317
+ });
318
+
238
319
  describe("stopping the run, which is a different press", () => {
239
320
  it("ends it at the next page boundary and says what it reached", async () => {
240
321
  const { hook } = mountEscalation({ query: "npm" });