@remit/web-client 0.0.199 → 0.0.201

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.
@@ -1,4 +1,5 @@
1
1
  import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
3
  import {
3
4
  type ClauseDraft,
4
5
  type ClauseEditState,
@@ -72,6 +73,12 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
72
73
  import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
73
74
  import type { WizardSelectionMessage } from "@/lib/wizard-selection";
74
75
  import { organizeRunState } from "./organize-run-state";
76
+ import {
77
+ retryIntent,
78
+ runIsInFlight,
79
+ stopRunner,
80
+ wizardExit,
81
+ } from "./selection-wizard-run-controls";
75
82
 
76
83
  const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
77
84
 
@@ -171,6 +178,34 @@ const NO_CONVERSION: SearchConversion = {
171
178
  /** A search entry has nothing ticked; its match is the query's clauses (#477 3.2). */
172
179
  const EMPTY_SELECTION: readonly WizardSelectionMessage[] = [];
173
180
 
181
+ /**
182
+ * What a search entry overrides on the props the surface handed over. Its rule
183
+ * belongs to the account the surface named, and to the one the query names when
184
+ * it names one (#524) — reaching past both for the first configured account
185
+ * writes the rule against whichever account happens to be first in the list, and
186
+ * the mail the user was looking at is never filtered.
187
+ */
188
+ export const searchEntryOverrides = (
189
+ conversion: SearchConversion,
190
+ props: Pick<SelectionWizardHostProps, "accountId">,
191
+ accounts: readonly Pick<RemitImapAccountResponse, "accountId">[],
192
+ ): Pick<
193
+ SelectionWizardSessionProps,
194
+ | "verb"
195
+ | "accountId"
196
+ | "selection"
197
+ | "selectionRestriction"
198
+ | "escalated"
199
+ | "searchConversion"
200
+ > => ({
201
+ verb: "organize",
202
+ accountId: searchRuleAccountId(conversion, props.accountId, accounts),
203
+ selection: EMPTY_SELECTION,
204
+ selectionRestriction: undefined,
205
+ escalated: undefined,
206
+ searchConversion: conversion,
207
+ });
208
+
174
209
  /** How far a commit has got, whichever of the three ways it took. */
175
210
  interface RunSnapshot {
176
211
  state: RunState;
@@ -851,42 +886,39 @@ function SelectionWizardSession({
851
886
  // walking back to Review, which would push an entry the wizard does not own
852
887
  // and leave Cancel rewinding to a step instead of out.
853
888
  const retry = (): void => {
854
- // A status that could not be read is a job still running on the server, so
855
- // the screen looks at that job again rather than queuing a second pass over
856
- // the same mail (#526).
857
- if (run.state === "statusUnknown") {
858
- organizeJob.refreshStatus();
859
- return;
860
- }
861
- // The predicate is re-resolved, not resumed: every verb it carries is
862
- // idempotent, so the messages the first pass already reached are a no-op.
863
- if (escalated) {
864
- void runEscalated();
865
- return;
866
- }
867
- if (committedScope === "standing" || committedScope === "temporary") {
868
- if (createFilter.isError) {
889
+ const intent = retryIntent({
890
+ runState: run.state,
891
+ isEscalated: escalated !== undefined,
892
+ committedScope,
893
+ createFilterFailed: createFilter.isError,
894
+ backApplyPending: backApplyDraft !== undefined,
895
+ widenRunsAsJob: widenedRunsAsJob(verb),
896
+ failedIds: bulkRun?.outcome?.failedIds ?? [],
897
+ sent: bulkRun?.sent ?? bulkTargets,
898
+ });
899
+ switch (intent.kind) {
900
+ case "refreshStatus":
901
+ organizeJob.refreshStatus();
902
+ return;
903
+ case "rerunEscalated":
904
+ void runEscalated();
905
+ return;
906
+ case "resetAndResend":
869
907
  createFilter.reset();
870
908
  sendCommit();
871
909
  return;
872
- }
873
- if (backApplyDraft) startJob(backApplyDraft);
874
- return;
875
- }
876
- if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
877
- sendCommit();
878
- return;
910
+ case "resend":
911
+ sendCommit();
912
+ return;
913
+ case "startBackApply":
914
+ if (backApplyDraft) startJob(backApplyDraft);
915
+ return;
916
+ case "waitOnJob":
917
+ return;
918
+ case "rerunBulk":
919
+ void runBulk(intent.targets);
920
+ return;
879
921
  }
880
- const outstanding = new Set(bulkRun?.outcome?.failedIds ?? []);
881
- // A run hands back ids, and each one's account came from the batch it was
882
- // sent in — so a retry re-sends the targets the run was given, filtered to
883
- // what it never reached.
884
- const sent = bulkRun?.sent ?? bulkTargets;
885
- void runBulk(
886
- outstanding.size > 0
887
- ? sent.filter((target) => outstanding.has(target.id))
888
- : sent,
889
- );
890
922
  };
891
923
 
892
924
  // Cancel rewinds the entries the wizard owns and leaves the selection where
@@ -905,21 +937,9 @@ function SelectionWizardSession({
905
937
  onFinished();
906
938
  }, [closeWizard, steps, current, onFinished]);
907
939
 
908
- // Ends the run rather than leaving it. The escalated predicate is run by the
909
- // list's runner and a bounded selection by this wizard's own, so the stop
910
- // follows whichever one is paging.
911
940
  const { stop: stopBulk } = bulk;
912
- // A commit that never started has nothing paging and nothing to end.
913
- const runInFlight =
914
- bulkRun !== undefined &&
915
- bulkRun.outcome === undefined &&
916
- bulkRun.failureReason === undefined;
917
941
  const stopRun = useCallback(() => {
918
- if (escalated) {
919
- escalated.stop();
920
- return;
921
- }
922
- stopBulk();
942
+ stopRunner(escalated, stopBulk);
923
943
  }, [escalated, stopBulk]);
924
944
 
925
945
  // Hardware back on the run screen leaves the wizard, the movement the header
@@ -971,7 +991,7 @@ function SelectionWizardSession({
971
991
  // Every exit from the run screen is the same movement: the header's X,
972
992
  // the header arrow, the footer and hardware Back all leave a run that
973
993
  // has already been sent, and all of them drop the selection with it.
974
- onExit={current === "run" ? dismiss : cancel}
994
+ onExit={wizardExit(current, { dismiss, cancel })}
975
995
  onContinue={advance}
976
996
  onCommit={commit}
977
997
  match={{
@@ -1051,7 +1071,7 @@ function SelectionWizardSession({
1051
1071
  failureReason: run.failureReason,
1052
1072
  onRetry: retry,
1053
1073
  onDismiss: dismiss,
1054
- onCancelRun: runInFlight ? stopRun : undefined,
1074
+ onCancelRun: runIsInFlight(bulkRun) ? stopRun : undefined,
1055
1075
  }}
1056
1076
  />
1057
1077
  );
@@ -1084,20 +1104,7 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
1084
1104
  return (
1085
1105
  <SelectionWizardSession
1086
1106
  {...props}
1087
- {...(conversion
1088
- ? {
1089
- verb: "organize" as const,
1090
- accountId: searchRuleAccountId(
1091
- conversion,
1092
- props.accountId,
1093
- accounts,
1094
- ),
1095
- selection: EMPTY_SELECTION,
1096
- selectionRestriction: undefined,
1097
- escalated: undefined,
1098
- searchConversion: conversion,
1099
- }
1100
- : {})}
1107
+ {...(conversion ? searchEntryOverrides(conversion, props, accounts) : {})}
1101
1108
  step={step}
1102
1109
  goToStep={goToStep}
1103
1110
  goBack={goBack}
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The roll-call no run can take: which surfaces exist, and which function each
3
+ * one's verbs name.
4
+ *
5
+ * Everything a selection surface *does* — the review screen naming what a verb
6
+ * covers, a run reaching the mail server — is driven end to end by
7
+ * `packages/e2e`. What no run observes is a surface that was never wired: a
8
+ * fourth pane growing its own direct handler while every other route was moved,
9
+ * a fourth list rendering the make-filter row with no wizard behind it, or a
10
+ * verb on a bar nobody presses in a spec quietly keeping a direct call. The
11
+ * suite has locators for Delete, Move and Organize and none for Junk or
12
+ * Mark-read, so three of the five are reachable only from here.
13
+ *
14
+ * So this reads the source, and reads it for the census: who raises a bar, what
15
+ * each of its verbs is wired to, who declares a keyboard verb over a selection,
16
+ * who renders the make-filter row. Every census is discovered from the
17
+ * directory rather than listed, so a fourth surface is a failure here until it
18
+ * is wired like the other three.
19
+ */
20
+
21
+ import assert from "node:assert/strict";
22
+ import { readdirSync, readFileSync } from "node:fs";
23
+ import { dirname, resolve } from "node:path";
24
+ import { describe, it } from "node:test";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const read = (file: string): string =>
29
+ readFileSync(resolve(here, file), "utf8");
30
+
31
+ const sourceFiles = (): string[] =>
32
+ readdirSync(here).filter((file) => file.endsWith(".tsx"));
33
+
34
+ const BAR_VERB_PROPS = [
35
+ "onDelete",
36
+ "onMove",
37
+ "onOrganize",
38
+ "onJunk",
39
+ "onMarkRead",
40
+ ] as const;
41
+
42
+ /**
43
+ * How a surface may answer a verb prop. `startWizard`/`wizard.start`/
44
+ * `startSelectionVerb` open the wizard on the verb; `organizeSelection` and
45
+ * `startFromSearch` open it on the search entry, which is the same wizard
46
+ * reached by its other door (#477 1.8). Anything else routes around the review
47
+ * screen that names what the verb covers (#477 1.4).
48
+ */
49
+ const OPENS_THE_WIZARD =
50
+ /^(startWizard|wizard\.start|startSelectionVerb|organizeSelection|startFromSearch)\b/;
51
+
52
+ const bracedFrom = (source: string, open: number): string => {
53
+ let depth = 0;
54
+ for (let i = open; i < source.length; i++) {
55
+ if (source[i] === "{") depth++;
56
+ else if (source[i] === "}") {
57
+ depth--;
58
+ if (depth === 0) return source.slice(open, i + 1);
59
+ }
60
+ }
61
+ return source.slice(open);
62
+ };
63
+
64
+ const namedCallbackBody = (source: string, name: string): string => {
65
+ const at = source.indexOf(`const ${name} = useCallback(`);
66
+ if (at === -1) return "";
67
+ const open = source.indexOf("{", at);
68
+ return open === -1 ? "" : bracedFrom(source, open);
69
+ };
70
+
71
+ /**
72
+ * The bar element as written, to its own closing tag. Found by matching tags
73
+ * rather than by stopping at the first `/>` on a line: a nested self-closing
74
+ * element matches that too, so the scan would end early and silently skip
75
+ * whatever a surface happened to write after it.
76
+ */
77
+ const barMarkup = (source: string): string => {
78
+ const start = source.indexOf("<SelectionTopBar");
79
+ if (start === -1) return "";
80
+ let depth = 0;
81
+ for (let i = start; i < source.length; i++) {
82
+ if (source[i] === "<" && /[A-Za-z]/.test(source[i + 1] ?? "")) depth++;
83
+ else if (source.startsWith("/>", i)) {
84
+ depth--;
85
+ if (depth === 0) return source.slice(start, i + 2);
86
+ i++;
87
+ } else if (source.startsWith("</", i)) depth--;
88
+ }
89
+ return "";
90
+ };
91
+
92
+ /**
93
+ * What a verb prop is wired to, with the whitespace, the guard a verb is
94
+ * offered behind, and the arrow taken off — what is left is the call itself.
95
+ */
96
+ const verbHandler = (bar: string, prop: string): string | undefined => {
97
+ const assigned = bar.match(new RegExp(`${prop}=\\{([\\s\\S]*?)\\}\\n`))?.[1];
98
+ if (assigned === undefined) return undefined;
99
+ return assigned
100
+ .replace(/\s+/g, " ")
101
+ .replace(/^.*? \? /, "")
102
+ .replace(/ : undefined$/, "")
103
+ .replace(/^\(\)\s*=>\s*/, "")
104
+ .trim();
105
+ };
106
+
107
+ const declaredHandlers = (source: string): string[] => {
108
+ const at = source.indexOf("handlers: {");
109
+ if (at === -1) return [];
110
+ return Array.from(
111
+ bracedFrom(source, source.indexOf("{", at)).matchAll(/\n\t{3}(\w+): /g),
112
+ (match) => match[1],
113
+ );
114
+ };
115
+
116
+ /**
117
+ * What a pane does for one keyboard verb: the handler it wired into the triage
118
+ * layer, plus any callback that handler defers to for its target. A handler
119
+ * that offers the list the press and then falls through to a helper picking the
120
+ * selection is exactly as wrong as one picking it inline, so both are read.
121
+ */
122
+ const paneVerbBody = (source: string, handler: string): string => {
123
+ const at = source.search(new RegExp(`\\n\\t{3}${handler}: `));
124
+ if (at === -1) return "";
125
+ const value =
126
+ source.slice(at).match(new RegExp(`${handler}: (.*)`))?.[1] ?? "";
127
+ const body = value.startsWith("(")
128
+ ? bracedFrom(source, source.indexOf("{", at))
129
+ : namedCallbackBody(source, value.replace(/[,\s].*$/, ""));
130
+ const helpers = Array.from(body.matchAll(/\b(\w+MessageIds)\(\)/g), (match) =>
131
+ namedCallbackBody(source, match[1]),
132
+ );
133
+ return [body, ...helpers].join("\n");
134
+ };
135
+
136
+ describe("the surfaces that raise a selection bar", () => {
137
+ const surfaces = (): string[] =>
138
+ sourceFiles()
139
+ .filter((file) => /<SelectionTopBar/.test(read(file)))
140
+ .sort();
141
+
142
+ it("are these three and no others", () => {
143
+ assert.deepEqual(surfaces(), [
144
+ "DailyBrief.tsx",
145
+ "MessageList.tsx",
146
+ "ThreadListInteraction.tsx",
147
+ ]);
148
+ });
149
+
150
+ for (const file of surfaces()) {
151
+ it(`${file} routes every verb it offers through the wizard`, () => {
152
+ const bar = barMarkup(read(file));
153
+ assert.notEqual(bar, "", `${file} renders a SelectionTopBar`);
154
+ const offered = BAR_VERB_PROPS.filter((prop) => bar.includes(`${prop}=`));
155
+ assert.ok(offered.length > 0, `${file} offers at least one verb`);
156
+ for (const prop of offered) {
157
+ const handler = verbHandler(bar, prop);
158
+ assert.ok(handler, `${file}'s ${prop} is wired to something`);
159
+ assert.match(
160
+ handler,
161
+ OPENS_THE_WIZARD,
162
+ `${file}'s ${prop} must open the wizard, not run the verb`,
163
+ );
164
+ }
165
+ });
166
+ }
167
+
168
+ for (const file of ["DailyBrief.tsx", "MessageList.tsx"]) {
169
+ it(`${file} drops none of the five verbs from the bar it raises`, () => {
170
+ const bar = barMarkup(read(file));
171
+ for (const prop of BAR_VERB_PROPS) {
172
+ assert.match(bar, new RegExp(`${prop}=`), `${file} offers ${prop}`);
173
+ }
174
+ });
175
+ }
176
+ });
177
+
178
+ // A pane that runs a verb over `selectedIds` without offering the list the
179
+ // press first is the hole no run can see: the pane's fallback and the list's
180
+ // claim are indistinguishable from outside until a selection outlives the
181
+ // list's commands. That the list claims the press is rendered in
182
+ // `./ThreadListInteraction.test.ts`.
183
+ describe("the panes that answer a keyboard verb over a selection", () => {
184
+ const PANE_VERB_ROUTE: Record<string, string> = {
185
+ delete: "delete",
186
+ toggleRead: "markRead",
187
+ markJunk: "junk",
188
+ };
189
+
190
+ const panes = (): string[] =>
191
+ sourceFiles()
192
+ .filter((file) => read(file).includes("handlers: {"))
193
+ .sort();
194
+
195
+ const routed = (): string[] =>
196
+ panes()
197
+ .flatMap((file) =>
198
+ declaredHandlers(read(file))
199
+ .filter((handler) => handler in PANE_VERB_ROUTE)
200
+ .map((handler) => `${file}:${handler}`),
201
+ )
202
+ .sort();
203
+
204
+ it("are these three panes and no others", () => {
205
+ assert.deepEqual(panes(), [
206
+ "BriefPane.tsx",
207
+ "FlaggedPane.tsx",
208
+ "MailboxPane.tsx",
209
+ ]);
210
+ });
211
+
212
+ it("declare these seven routed handlers and no others", () => {
213
+ assert.deepEqual(routed(), [
214
+ "BriefPane.tsx:delete",
215
+ "BriefPane.tsx:toggleRead",
216
+ "FlaggedPane.tsx:delete",
217
+ "FlaggedPane.tsx:toggleRead",
218
+ "MailboxPane.tsx:delete",
219
+ "MailboxPane.tsx:markJunk",
220
+ "MailboxPane.tsx:toggleRead",
221
+ ]);
222
+ });
223
+
224
+ for (const entry of routed()) {
225
+ const [file, handler] = entry.split(":");
226
+ const verb = PANE_VERB_ROUTE[handler];
227
+
228
+ it(`${entry} offers the list ${verb} before running anything`, () => {
229
+ const body = paneVerbBody(read(file), handler);
230
+ assert.notEqual(body, "", `${handler}'s body was found`);
231
+ assert.match(body, new RegExp(`requestVerb\\("${verb}"\\)`));
232
+ });
233
+
234
+ it(`${entry} falls back to the focused row, never to a selection`, () => {
235
+ const body = paneVerbBody(read(file), handler);
236
+ assert.notEqual(body, "", `${handler}'s body was found`);
237
+ assert.doesNotMatch(body, /selectedIds/i);
238
+ });
239
+ }
240
+ });
241
+
242
+ // The row is built once by `MailListHeader` and handed down as `makeFilterSlot`,
243
+ // but the wizard answering the step it pushes is mounted per surface. A surface
244
+ // that renders the row without one leaves the press on a URL nothing answers.
245
+ describe("the surfaces that render the make-filter row", () => {
246
+ const ANSWERED_BY: Record<string, string> = {
247
+ "DailyBrief.tsx": "DailyBrief.tsx",
248
+ "MessageList.tsx": "MessageList.tsx",
249
+ "ThreadListInteraction.tsx": "FlaggedList.tsx",
250
+ };
251
+
252
+ it("are these three and no others", () => {
253
+ assert.deepEqual(
254
+ sourceFiles()
255
+ .filter((file) => /\.makeFilterSlot/.test(read(file)))
256
+ .sort(),
257
+ Object.keys(ANSWERED_BY).sort(),
258
+ );
259
+ });
260
+
261
+ it("each name a file that mounts a wizard to answer the step", () => {
262
+ for (const [surface, host] of Object.entries(ANSWERED_BY)) {
263
+ assert.match(
264
+ read(host),
265
+ /<SelectionWizardHost/,
266
+ `${surface} is answered by ${host}`,
267
+ );
268
+ }
269
+ });
270
+ });
@@ -0,0 +1,230 @@
1
+ /**
2
+ * What the run screen's controls decide (#521, #526).
3
+ *
4
+ * The wizard host wires routing, history and several data hooks together, so
5
+ * the decision is taken out of it and asserted by its result — the same move
6
+ * `runEndingBanner` made for the completion banner. What the run screen then
7
+ * shows for each state is `@remit/ui`'s `selection-wizard.render.test.ts`, and
8
+ * the poll a refresh re-issues is `../../hooks/useOrganizeJob.render.test.ts`.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import type { BulkActionTarget, BulkRunOutcome } from "@/lib/bulk-actions";
14
+ import {
15
+ type RetryContext,
16
+ retryIntent,
17
+ runIsInFlight,
18
+ stopRunner,
19
+ wizardExit,
20
+ } from "./selection-wizard-run-controls";
21
+
22
+ const target = (id: string): BulkActionTarget => ({ id, accountId: "acc-1" });
23
+
24
+ const context = (over: Partial<RetryContext> = {}): RetryContext => ({
25
+ runState: "backApplyComplete",
26
+ isEscalated: false,
27
+ committedScope: "just-these",
28
+ createFilterFailed: false,
29
+ backApplyPending: false,
30
+ widenRunsAsJob: false,
31
+ failedIds: [],
32
+ sent: [target("m1"), target("m2")],
33
+ ...over,
34
+ });
35
+
36
+ describe("retrying a run whose status could not be read", () => {
37
+ it("looks at the job it already has rather than queuing a second pass", () => {
38
+ assert.deepEqual(retryIntent(context({ runState: "statusUnknown" })), {
39
+ kind: "refreshStatus",
40
+ });
41
+ });
42
+
43
+ // The back-apply is still running on the server, so every other branch —
44
+ // each of which starts something — has to lose to this one.
45
+ for (const over of [
46
+ { isEscalated: true },
47
+ { committedScope: "standing" as const, backApplyPending: true },
48
+ { committedScope: "temporary" as const, createFilterFailed: true },
49
+ { committedScope: "all-like-these" as const, widenRunsAsJob: true },
50
+ { committedScope: "just-these" as const },
51
+ ]) {
52
+ it(`wins over ${JSON.stringify(over)}`, () => {
53
+ assert.deepEqual(
54
+ retryIntent(context({ ...over, runState: "statusUnknown" })).kind,
55
+ "refreshStatus",
56
+ );
57
+ });
58
+ }
59
+ });
60
+
61
+ describe("retrying an escalated predicate", () => {
62
+ it("re-resolves the predicate rather than resuming a page list", () => {
63
+ assert.deepEqual(retryIntent(context({ isEscalated: true })), {
64
+ kind: "rerunEscalated",
65
+ });
66
+ });
67
+ });
68
+
69
+ describe("retrying a filter that was being saved", () => {
70
+ it("clears the failed create before sending the same commit again", () => {
71
+ assert.deepEqual(
72
+ retryIntent(
73
+ context({ committedScope: "standing", createFilterFailed: true }),
74
+ ),
75
+ { kind: "resetAndResend" },
76
+ );
77
+ });
78
+
79
+ it("starts the pass over existing mail when the create landed", () => {
80
+ assert.deepEqual(
81
+ retryIntent(
82
+ context({ committedScope: "temporary", backApplyPending: true }),
83
+ ),
84
+ { kind: "startBackApply" },
85
+ );
86
+ });
87
+
88
+ it("starts nothing for a saved filter with no pass behind it", () => {
89
+ assert.deepEqual(retryIntent(context({ committedScope: "standing" })), {
90
+ kind: "waitOnJob",
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("retrying a widened match", () => {
96
+ it("re-sends the commit when the widen runs as a job", () => {
97
+ assert.deepEqual(
98
+ retryIntent(
99
+ context({ committedScope: "all-like-these", widenRunsAsJob: true }),
100
+ ),
101
+ { kind: "resend" },
102
+ );
103
+ });
104
+
105
+ it("re-sends the resolved ids when it does not", () => {
106
+ assert.deepEqual(
107
+ retryIntent(context({ committedScope: "all-like-these" })),
108
+ { kind: "rerunBulk", targets: [target("m1"), target("m2")] },
109
+ );
110
+ });
111
+ });
112
+
113
+ describe("retrying a bounded run", () => {
114
+ it("re-sends only what the run never reached", () => {
115
+ assert.deepEqual(
116
+ retryIntent(
117
+ context({ sent: [target("m1"), target("m2")], failedIds: ["m2"] }),
118
+ ),
119
+ { kind: "rerunBulk", targets: [target("m2")] },
120
+ );
121
+ });
122
+
123
+ // A run that threw before it handed anything back reports no failed ids, and
124
+ // re-sending nothing is a retry that silently does nothing at all.
125
+ it("re-sends everything when the run named no id it missed", () => {
126
+ assert.deepEqual(retryIntent(context()), {
127
+ kind: "rerunBulk",
128
+ targets: [target("m1"), target("m2")],
129
+ });
130
+ });
131
+
132
+ it("keeps each id's own account, so a cross-account retry still batches", () => {
133
+ const sent = [
134
+ { id: "m1", accountId: "acc-work" },
135
+ { id: "m2", accountId: "acc-personal" },
136
+ ];
137
+ assert.deepEqual(retryIntent(context({ sent, failedIds: ["m2"] })), {
138
+ kind: "rerunBulk",
139
+ targets: [{ id: "m2", accountId: "acc-personal" }],
140
+ });
141
+ });
142
+ });
143
+
144
+ describe("whether the run screen offers a way to end the run", () => {
145
+ const outcome: BulkRunOutcome = {
146
+ done: 2,
147
+ failedIds: [],
148
+ cancelled: false,
149
+ };
150
+
151
+ it("offers nothing before a commit has been sent", () => {
152
+ assert.equal(runIsInFlight(undefined), false);
153
+ });
154
+
155
+ it("offers it while the runner is still paging", () => {
156
+ assert.equal(runIsInFlight({}), true);
157
+ });
158
+
159
+ it("withdraws it once the runner has handed back an outcome", () => {
160
+ assert.equal(runIsInFlight({ outcome }), false);
161
+ });
162
+
163
+ // Nothing was sent, so there is nothing paging to end — the screen carries
164
+ // why instead, and a control claiming to stop a run would stop nothing.
165
+ it("withdraws it for a commit that could not start", () => {
166
+ assert.equal(runIsInFlight({ failureReason: "no Junk folder" }), false);
167
+ });
168
+ });
169
+
170
+ describe("which runner the stop reaches", () => {
171
+ const spies = () => {
172
+ const called: string[] = [];
173
+ return {
174
+ called,
175
+ escalated: { stop: () => called.push("escalated") },
176
+ stopBulk: () => called.push("bulk"),
177
+ };
178
+ };
179
+
180
+ it("stops the list's runner when the selection is a predicate", () => {
181
+ const { called, escalated, stopBulk } = spies();
182
+ stopRunner(escalated, stopBulk);
183
+ assert.deepEqual(called, ["escalated"]);
184
+ });
185
+
186
+ // A bounded select-all runs on the wizard's own runner and reaches the same
187
+ // control, so a stop that only knows about the predicate leaves that button
188
+ // on screen doing nothing at all.
189
+ it("stops the wizard's own runner for a bounded selection", () => {
190
+ const { called, stopBulk } = spies();
191
+ stopRunner(undefined, stopBulk);
192
+ assert.deepEqual(called, ["bulk"]);
193
+ });
194
+ });
195
+
196
+ describe("which movement the wizard's exit is", () => {
197
+ const movements = () => {
198
+ const called: string[] = [];
199
+ return {
200
+ called,
201
+ handlers: {
202
+ dismiss: () => called.push("dismiss"),
203
+ cancel: () => called.push("cancel"),
204
+ },
205
+ };
206
+ };
207
+
208
+ it("walks away from the run on the run screen, leaving it going", () => {
209
+ const { called, handlers } = movements();
210
+ wizardExit("run", handlers)();
211
+ assert.deepEqual(called, ["dismiss"]);
212
+ });
213
+
214
+ // Rewinding the entries the wizard owns is only safe before anything is
215
+ // sent; taken off the run screen it offers the commit a second time.
216
+ for (const step of [
217
+ "match",
218
+ "properties",
219
+ "folder",
220
+ "rule",
221
+ "name",
222
+ "review",
223
+ ] as const) {
224
+ it(`rewinds the walk from ${step}`, () => {
225
+ const { called, handlers } = movements();
226
+ wizardExit(step, handlers)();
227
+ assert.deepEqual(called, ["cancel"]);
228
+ });
229
+ }
230
+ });