@remit/web-client 0.0.105 → 0.0.107

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.105",
3
+ "version": "0.0.107",
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": {
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * A back-apply whose status could not be read is still running on the server
9
+ * (#526), so the run screen's affordance looks at that job again rather than
10
+ * queuing a second pass over the same mail.
11
+ *
12
+ * The host wires routing, history and several data hooks together, so — as with
13
+ * this package's other component-level rules (see `SelectionWizardHost.run-exit.test.ts`)
14
+ * — the wiring is read off the source. What it decides is proven where it can be
15
+ * run: `./organize-run-state.test.ts` for the ending the screen shows, and
16
+ * `../../hooks/useOrganizeJob.render.test.ts` for the poll it re-issues.
17
+ */
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
21
+
22
+ const retryBody = (): string => {
23
+ const body = source.match(
24
+ /const retry = \(\): void => \{([\s\S]*?)\n\t\};/,
25
+ )?.[1];
26
+ assert.ok(body, "the run screen has no retry");
27
+ return body;
28
+ };
29
+
30
+ describe("retrying from an unknown status", () => {
31
+ it("looks at the job it already has", () => {
32
+ const body = retryBody();
33
+ const unknown = body.indexOf('run.state === "statusUnknown"');
34
+ assert.ok(unknown >= 0, "retry does not recognise an unknown status");
35
+ assert.match(body.slice(unknown), /organizeJob\.refreshStatus\(\)/);
36
+ });
37
+
38
+ it("decides that before it can reach anything that queues a second job", () => {
39
+ const body = retryBody();
40
+ assert.ok(
41
+ body.indexOf('run.state === "statusUnknown"') < body.indexOf("startJob("),
42
+ "a second back-apply can be queued over a job that is still running",
43
+ );
44
+ });
45
+
46
+ it("reads what the job is doing rather than that something failed", () => {
47
+ assert.doesNotMatch(source, /organizeJob\.isError/);
48
+ assert.match(source, /organizeRunState\(\{/);
49
+ });
50
+ });
@@ -65,6 +65,7 @@ import {
65
65
  import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
66
66
  import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
67
67
  import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
68
+ import { organizeRunState } from "./organize-run-state";
68
69
 
69
70
  const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
70
71
 
@@ -696,29 +697,34 @@ function SelectionWizardSession({
696
697
  sendCommit();
697
698
  }, [blockedReason, current, goToStep, sendCommit]);
698
699
 
699
- const jobSnapshot = useCallback((): RunSnapshot => {
700
- const progress = organizeJob.progress;
701
- const shared = {
702
- matched: progress.matchedCount,
703
- applied: progress.appliedCount,
704
- failures: [],
705
- };
706
- if (organizeJob.isError) {
707
- return { ...NOT_STARTED, state: "commitFailed" };
708
- }
709
- if (organizeJob.isDone) {
700
+ const jobSnapshot = useCallback(
701
+ (ruleSaved: boolean): RunSnapshot => {
702
+ const progress = organizeJob.progress;
703
+ const state = organizeRunState({
704
+ failure: organizeJob.failure,
705
+ isStarting: organizeJob.isStarting,
706
+ isRunning: organizeJob.isRunning,
707
+ isDone: organizeJob.isDone,
708
+ failedCount: progress.failedCount,
709
+ ruleSaved,
710
+ });
711
+ if (
712
+ state === "saving" ||
713
+ state === "commitFailed" ||
714
+ state === "backApplyStartFailed"
715
+ ) {
716
+ return { ...NOT_STARTED, state };
717
+ }
710
718
  return {
711
- ...shared,
712
- state:
713
- progress.failedCount > 0 ? "backApplyFailed" : "backApplyComplete",
714
- failed: progress.failedCount,
719
+ state,
720
+ matched: progress.matchedCount,
721
+ applied: progress.appliedCount,
722
+ failed: state === "backApplyFailed" ? progress.failedCount : 0,
723
+ failures: [],
715
724
  };
716
- }
717
- if (organizeJob.isStarting || organizeJob.isRunning) {
718
- return { ...shared, state: "backApplyRunning", failed: 0 };
719
- }
720
- return NOT_STARTED;
721
- }, [organizeJob]);
725
+ },
726
+ [organizeJob],
727
+ );
722
728
 
723
729
  // Every row the wizard has seen a description of, so a run that names what it
724
730
  // did not reach can name it rather than listing an id.
@@ -794,21 +800,27 @@ function SelectionWizardSession({
794
800
  return { ...NOT_STARTED, state: "commitFailed" };
795
801
  if (!createFilter.isSuccess) return NOT_STARTED;
796
802
  if (!backApplyDraft) return { ...NOT_STARTED, state: "filterSaved" };
797
- if (organizeJob.isError) {
798
- return { ...NOT_STARTED, state: "backApplyStartFailed" };
799
- }
800
- return jobSnapshot();
803
+ return jobSnapshot(true);
801
804
  }
802
805
  if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
803
- return jobSnapshot();
806
+ return jobSnapshot(false);
804
807
  }
805
808
  return bulkSnapshot();
806
809
  };
807
810
 
811
+ const run = runSnapshot();
812
+
808
813
  // Retry stays on the run screen: it re-sends the same commit rather than
809
814
  // walking back to Review, which would push an entry the wizard does not own
810
815
  // and leave Cancel rewinding to a step instead of out.
811
816
  const retry = (): void => {
817
+ // A status that could not be read is a job still running on the server, so
818
+ // the screen looks at that job again rather than queuing a second pass over
819
+ // the same mail (#526).
820
+ if (run.state === "statusUnknown") {
821
+ organizeJob.refreshStatus();
822
+ return;
823
+ }
812
824
  // The predicate is re-resolved, not resumed: every verb it carries is
813
825
  // idempotent, so the messages the first pass already reached are a no-op.
814
826
  if (escalated) {
@@ -881,7 +893,6 @@ function SelectionWizardSession({
881
893
  disabled: current !== "run",
882
894
  });
883
895
 
884
- const run = runSnapshot();
885
896
  const sampleMessages = escalated
886
897
  ? escalatedSample.messages
887
898
  : previewed
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A back-apply job reports two things that used to arrive as one error flag: it
3
+ * never started, or its status could not be read (#526). The run screen is only
4
+ * allowed to say nothing happened for the first.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import {
10
+ type OrganizeJobReading,
11
+ organizeRunState,
12
+ } from "./organize-run-state";
13
+
14
+ const reading = (
15
+ over: Partial<OrganizeJobReading> = {},
16
+ ): OrganizeJobReading => ({
17
+ failure: undefined,
18
+ isStarting: false,
19
+ isRunning: false,
20
+ isDone: false,
21
+ failedCount: 0,
22
+ ruleSaved: false,
23
+ ...over,
24
+ });
25
+
26
+ describe("organizeRunState", () => {
27
+ it("keeps a running job running when a status poll could not be read", () => {
28
+ const state = organizeRunState(
29
+ reading({
30
+ isRunning: true,
31
+ failure: { kind: "statusUnreadable", error: new Error("offline") },
32
+ }),
33
+ );
34
+ assert.equal(state, "statusUnknown");
35
+ assert.notEqual(state, "commitFailed");
36
+ });
37
+
38
+ it("keeps a finished job finished when a later poll fails", () => {
39
+ // Polling stops on a terminal state, so the failing read is a window-focus
40
+ // refetch over a job that already reported its counts.
41
+ assert.equal(
42
+ organizeRunState(
43
+ reading({
44
+ isDone: true,
45
+ failure: { kind: "statusUnreadable", error: new Error("offline") },
46
+ }),
47
+ ),
48
+ "backApplyComplete",
49
+ );
50
+ assert.equal(
51
+ organizeRunState(
52
+ reading({
53
+ isDone: true,
54
+ failedCount: 3,
55
+ failure: { kind: "statusUnreadable", error: new Error("offline") },
56
+ }),
57
+ ),
58
+ "backApplyFailed",
59
+ );
60
+ });
61
+
62
+ it("says nothing happened only when the create itself failed", () => {
63
+ assert.equal(
64
+ organizeRunState(
65
+ reading({ failure: { kind: "startFailed", error: new Error("nope") } }),
66
+ ),
67
+ "commitFailed",
68
+ );
69
+ });
70
+
71
+ it("leaves a saved rule standing when its pass never started", () => {
72
+ assert.equal(
73
+ organizeRunState(
74
+ reading({
75
+ ruleSaved: true,
76
+ failure: { kind: "startFailed", error: new Error("nope") },
77
+ }),
78
+ ),
79
+ "backApplyStartFailed",
80
+ );
81
+ });
82
+
83
+ it("reports a job that is starting or polling cleanly as running", () => {
84
+ assert.equal(
85
+ organizeRunState(reading({ isStarting: true })),
86
+ "backApplyRunning",
87
+ );
88
+ assert.equal(
89
+ organizeRunState(reading({ isRunning: true })),
90
+ "backApplyRunning",
91
+ );
92
+ });
93
+
94
+ it("has nothing to report before a commit", () => {
95
+ assert.equal(organizeRunState(reading()), "saving");
96
+ });
97
+ });
@@ -0,0 +1,39 @@
1
+ import type { RunState } from "@remit/ui";
2
+ import type { OrganizeJobFailure } from "@/hooks/useOrganizeJob";
3
+
4
+ /** What the run screen knows about a back-apply job it is reporting on. */
5
+ export interface OrganizeJobReading {
6
+ failure: OrganizeJobFailure | undefined;
7
+ isStarting: boolean;
8
+ isRunning: boolean;
9
+ isDone: boolean;
10
+ failedCount: number;
11
+ /**
12
+ * The pass belongs to a rule that already saved, so a pass that never started
13
+ * leaves the rule standing rather than leaving nothing behind.
14
+ */
15
+ ruleSaved: boolean;
16
+ }
17
+
18
+ /**
19
+ * What the run screen says the job is doing. A status that could not be read is
20
+ * not a job that never started (#526), so what the job is doing is read before
21
+ * what failed: a dropped poll leaves a running pass running and a finished one
22
+ * finished, and only a create that never returned an id says nothing happened.
23
+ */
24
+ export const organizeRunState = ({
25
+ failure,
26
+ isStarting,
27
+ isRunning,
28
+ isDone,
29
+ failedCount,
30
+ ruleSaved,
31
+ }: OrganizeJobReading): RunState => {
32
+ if (failure?.kind === "startFailed") {
33
+ return ruleSaved ? "backApplyStartFailed" : "commitFailed";
34
+ }
35
+ if (isDone) return failedCount > 0 ? "backApplyFailed" : "backApplyComplete";
36
+ if (failure?.kind === "statusUnreadable") return "statusUnknown";
37
+ if (isStarting || isRunning) return "backApplyRunning";
38
+ return "saving";
39
+ };
@@ -150,6 +150,30 @@ const flush = async () => {
150
150
  }
151
151
  };
152
152
 
153
+ const pickRow = async (label: string) => {
154
+ const row = container.querySelector<HTMLButtonElement>(
155
+ `button[aria-label="Move to ${label}"]`,
156
+ );
157
+ assert.ok(row, `the picker offers ${label}`);
158
+ await act(async () => {
159
+ row.click();
160
+ });
161
+ };
162
+
163
+ const confirmMove = async (label: RegExp) => {
164
+ const confirm = buttonByText(label);
165
+ assert.ok(confirm, `the confirm reads ${label}`);
166
+ await act(async () => {
167
+ confirm.click();
168
+ });
169
+ };
170
+
171
+ const clickMoveToArchive = async () => {
172
+ act(() => buttonByText(/Move them to another folder/)?.click());
173
+ await pickRow("Archive");
174
+ await confirmMove(/^Move 3 emails to Archive$/);
175
+ };
176
+
153
177
  describe("DeleteFolderDialog", () => {
154
178
  it("renders nothing when closed", () => {
155
179
  render({ open: false, folder: mailboxes[1] as RemitImapMailboxResponse });
@@ -210,6 +234,123 @@ describe("DeleteFolderDialog", () => {
210
234
  assert.match(options, /Archive/);
211
235
  });
212
236
 
237
+ it("opens a branch without moving anything, and commits only on confirm", async () => {
238
+ const nested = [
239
+ ...mailboxes,
240
+ mailbox({ mailboxId: "work", fullPath: "Work" }),
241
+ mailbox({ mailboxId: "clients", fullPath: "Work/Clients" }),
242
+ ];
243
+ const moveCalls: string[][] = [];
244
+ const movedOnServer = new Set<string>();
245
+ let deleted = false;
246
+ route = ({ url, method, body: reqBody }) => {
247
+ if (method === "DELETE") {
248
+ deleted = true;
249
+ return new Response(null, { status: 204 });
250
+ }
251
+ if (url.includes("/messages/move")) {
252
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
253
+ moveCalls.push(body.messageIds);
254
+ for (const id of body.messageIds) movedOnServer.add(id);
255
+ return json({ moved: body.messageIds.length });
256
+ }
257
+ if (url.includes("/threads"))
258
+ return json(threadItems(["m1"].filter((id) => !movedOnServer.has(id))));
259
+ return json({ items: nested });
260
+ };
261
+ render({
262
+ open: true,
263
+ folder: mailboxes[1] as RemitImapMailboxResponse,
264
+ allMailboxes: nested,
265
+ });
266
+ act(() => buttonByText(/Move them to another folder/)?.click());
267
+ assert.equal(
268
+ buttonByText(/^Move 3 emails to/),
269
+ undefined,
270
+ "nothing is armed before a destination is picked",
271
+ );
272
+ await pickRow("Work");
273
+ await flush();
274
+ assert.deepEqual(moveCalls, [], "opening a branch starts no move");
275
+ assert.equal(deleted, false, "opening a branch deletes nothing");
276
+ assert.ok(
277
+ container.querySelector('button[aria-label="Move to Clients"]'),
278
+ "the tap opened the branch so the nested destination is reachable",
279
+ );
280
+
281
+ await pickRow("Clients");
282
+ await confirmMove(/^Move 3 emails to Clients$/);
283
+ await flush();
284
+ assert.deepEqual(moveCalls, [["m1"]], "the confirm commits the move");
285
+ assert.equal(deleted, true, "the emptied folder is deleted");
286
+ });
287
+
288
+ it("arms rather than commits when a folder is created to move into", async () => {
289
+ const filed = mailbox({
290
+ mailboxId: "filed",
291
+ fullPath: "Filed",
292
+ syncStatus: "synced",
293
+ });
294
+ const moveCalls: string[][] = [];
295
+ let deleted = false;
296
+ let createdOnServer = false;
297
+ route = ({ url, method, body: reqBody }) => {
298
+ if (method === "DELETE") {
299
+ deleted = true;
300
+ return new Response(null, { status: 204 });
301
+ }
302
+ if (url.includes("/messages/move")) {
303
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
304
+ moveCalls.push(body.messageIds);
305
+ return json({ moved: body.messageIds.length });
306
+ }
307
+ if (url.includes("/threads")) return json(threadItems(["m1"]));
308
+ if (method === "POST" && url.includes("/mailboxes")) {
309
+ createdOnServer = true;
310
+ return json(filed);
311
+ }
312
+ return json({
313
+ items: createdOnServer ? [...mailboxes, filed] : mailboxes,
314
+ });
315
+ };
316
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
317
+ await flush();
318
+ act(() => buttonByText(/Move them to another folder/)?.click());
319
+ act(() =>
320
+ container
321
+ .querySelector<HTMLButtonElement>('button[aria-label="New folder"]')
322
+ ?.click(),
323
+ );
324
+ const nameField = container.querySelector<HTMLInputElement>(
325
+ 'input:not([aria-label="Filter folders"])',
326
+ );
327
+ assert.ok(nameField, "the new-folder form is open");
328
+ act(() => {
329
+ Object.getOwnPropertyDescriptor(
330
+ HTMLInputElement.prototype,
331
+ "value",
332
+ )?.set?.call(nameField, "Filed");
333
+ nameField.dispatchEvent(new Event("input", { bubbles: true }));
334
+ });
335
+ await act(async () => {
336
+ buttonByText(/^Create folder$/)?.click();
337
+ });
338
+ await flush();
339
+ assert.deepEqual(moveCalls, [], "a created folder starts no move");
340
+ assert.equal(deleted, false, "a created folder deletes nothing");
341
+
342
+ render({
343
+ open: true,
344
+ folder: mailboxes[1] as RemitImapMailboxResponse,
345
+ allMailboxes: [...mailboxes, filed],
346
+ });
347
+ assert.ok(
348
+ buttonByText(/^Move 3 emails to Filed$/),
349
+ "the created folder is armed as the destination",
350
+ );
351
+ assert.deepEqual(moveCalls, [], "arming it is still not a move");
352
+ });
353
+
213
354
  it("deletes an empty folder and closes on success", async () => {
214
355
  let closed = false;
215
356
  route = ({ method }) => {
@@ -274,14 +415,7 @@ describe("DeleteFolderDialog", () => {
274
415
  closed = true;
275
416
  },
276
417
  });
277
- act(() => buttonByText(/Move them to another folder/)?.click());
278
- await act(async () => {
279
- (
280
- container.querySelector('button[aria-label="Move to Archive"]') as
281
- | HTMLButtonElement
282
- | undefined
283
- )?.click();
284
- });
418
+ await clickMoveToArchive();
285
419
  await flush();
286
420
  assert.deepEqual(moved.sort(), ["m1", "m2", "m3"]);
287
421
  assert.equal(closed, true);
@@ -323,30 +457,12 @@ describe("DeleteFolderDialog", () => {
323
457
  closed = true;
324
458
  },
325
459
  });
326
- act(() => buttonByText(/Move them to another folder/)?.click());
327
- await act(async () => {
328
- (
329
- container.querySelector('button[aria-label="Move to Archive"]') as
330
- | HTMLButtonElement
331
- | undefined
332
- )?.click();
333
- });
460
+ await clickMoveToArchive();
334
461
  await flush();
335
462
  assert.match(container.textContent ?? "", /stay moved/);
336
463
  assert.equal(closed, false);
337
464
  });
338
465
 
339
- const clickMoveToArchive = async () => {
340
- act(() => buttonByText(/Move them to another folder/)?.click());
341
- await act(async () => {
342
- (
343
- container.querySelector('button[aria-label="Move to Archive"]') as
344
- | HTMLButtonElement
345
- | undefined
346
- )?.click();
347
- });
348
- };
349
-
350
466
  it("iterates multiple batches and deletes only after the folder drains", async () => {
351
467
  let deleted = false;
352
468
  const moveCalls: string[][] = [];
@@ -54,6 +54,7 @@ export function DeleteFolderDialog({
54
54
  const [stage, setStage] = useState<FateStage>(() =>
55
55
  initialStage(folder.messageCount),
56
56
  );
57
+ const [destinationId, setDestinationId] = useState<string>();
57
58
  const { createFolderIn } = useCreateMailbox(accountId);
58
59
  const translator = useFolderLabelTranslator();
59
60
  const {
@@ -76,6 +77,11 @@ export function DeleteFolderDialog({
76
77
  reset();
77
78
  }, [open, folder.messageCount, reset]);
78
79
 
80
+ useEffect(() => {
81
+ if (!open) return;
82
+ setDestinationId(undefined);
83
+ }, [open]);
84
+
79
85
  useEffect(() => cancel, [cancel]);
80
86
 
81
87
  const handleClose = useCallback(() => {
@@ -94,6 +100,10 @@ export function DeleteFolderDialog({
94
100
  [mailboxes, appointments, folder.mailboxId, translator],
95
101
  );
96
102
 
103
+ const destination = destinations.find(
104
+ (option) => option.id === destinationId,
105
+ );
106
+
97
107
  const name = useMemo(
98
108
  () =>
99
109
  labelForMailbox(
@@ -265,18 +275,37 @@ export function DeleteFolderDialog({
265
275
  Move the {emailCount(folder.messageCount)} in{" "}
266
276
  <strong className="text-fg">{name}</strong> to:
267
277
  </p>
268
- <div className="min-h-0 flex-1">
278
+ <div className="flex min-h-0 flex-1 overflow-hidden">
269
279
  <FolderTreePicker
270
280
  folders={destinations}
281
+ selectedId={destinationId}
271
282
  delimiter={mailboxes[0]?.hierarchyDelimiter ?? "/"}
272
- onSelect={(destinationMailboxId) =>
273
- moveThenDelete(destinationMailboxId)
274
- }
283
+ onSelect={setDestinationId}
275
284
  onCreateFolder={createFolderIn}
276
- onCancel={() => setStage("choose-fate")}
285
+ onCancel={() => {
286
+ setDestinationId(undefined);
287
+ setStage("choose-fate");
288
+ }}
277
289
  labels={{ filterPlaceholder: "Move emails to…" }}
278
290
  />
279
291
  </div>
292
+ {/* Tapping a folder both picks it and opens it, so the move and the
293
+ delete wait for this confirmation — otherwise the first tap on the way
294
+ to a nested destination would empty the folder and remove it, with no
295
+ undo. */}
296
+ {destination && (
297
+ <footer className="shrink-0 border-t border-line p-2">
298
+ <Button
299
+ variant="danger"
300
+ onClick={() => moveThenDelete(destination.id)}
301
+ className="h-11 w-full font-semibold"
302
+ >
303
+ <span className="truncate">
304
+ {`Move ${emailCount(folder.messageCount)} to ${destination.label}`}
305
+ </span>
306
+ </Button>
307
+ </footer>
308
+ )}
280
309
  </div>
281
310
  );
282
311
  })();
@@ -0,0 +1,142 @@
1
+ /**
2
+ * useOrganizeJob — the back-apply job seam. It reports two failures that are not
3
+ * the same fact (#526): a create that never returned a job id, and a status poll
4
+ * that could not be read over a job the server is already running. Looking at
5
+ * that job again is a separate move from starting one.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { afterEach, describe, it } from "node:test";
10
+ import { act, createElement } from "react";
11
+ import type { OrganizeDraft } from "../lib/organize/organize-model";
12
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
13
+ import { type HttpMock, mockFetch } from "../test-support/http";
14
+ import { useOrganizeJob } from "./useOrganizeJob";
15
+
16
+ /**
17
+ * A poll that could not be read reaches this screen only as a transport failure:
18
+ * `shouldEscalate` puts every answered non-2xx on the full-screen fatal page, so
19
+ * a status that is merely unreadable is a `fetch` that never landed.
20
+ */
21
+ const dropped = (): never => {
22
+ throw new TypeError("fetch failed");
23
+ };
24
+
25
+ const ACCOUNT = "acc-1";
26
+ const JOB = "job-1";
27
+
28
+ const DRAFT: OrganizeDraft = {
29
+ matchOperator: "Or",
30
+ literalClauses: [{ field: "From", value: "noreply@example.com" }],
31
+ moveMailboxId: "mbx-1",
32
+ };
33
+
34
+ let harness: DomHarness | undefined;
35
+ let http: HttpMock | undefined;
36
+ let job: ReturnType<typeof useOrganizeJob> | undefined;
37
+
38
+ afterEach(() => {
39
+ harness?.close();
40
+ harness = undefined;
41
+ http?.restore();
42
+ http = undefined;
43
+ job = undefined;
44
+ });
45
+
46
+ function Probe() {
47
+ job = useOrganizeJob(ACCOUNT);
48
+ return null;
49
+ }
50
+
51
+ const current = (): ReturnType<typeof useOrganizeJob> => {
52
+ if (!job) throw new Error("useOrganizeJob is not mounted");
53
+ return job;
54
+ };
55
+
56
+ // A request round-trip needs a macrotask, not just a drained microtask queue.
57
+ const settle = async (): Promise<void> => {
58
+ await harness?.wait(1);
59
+ await harness?.flush();
60
+ };
61
+
62
+ /** Start a job the server accepts, then answer every status poll with `status`. */
63
+ const startJob = async (status: () => unknown): Promise<void> => {
64
+ http = mockFetch((call) => {
65
+ if (call.method === "POST") {
66
+ return { organizeJobId: JOB, state: "Pending" };
67
+ }
68
+ return status();
69
+ });
70
+ harness = createDomHarness();
71
+ harness.renderApp(createElement(Probe));
72
+ await act(async () => {
73
+ current().start(DRAFT);
74
+ });
75
+ await settle();
76
+ };
77
+
78
+ const posts = (): number =>
79
+ (http?.calls ?? []).filter((call) => call.method === "POST").length;
80
+
81
+ const statusPolls = (): number => (http?.to(`/organize/${JOB}`) ?? []).length;
82
+
83
+ describe("useOrganizeJob status reporting", () => {
84
+ it("reports a status poll it could not read as unreadable, over a job still running", async () => {
85
+ await startJob(dropped);
86
+ assert.equal(current().failure?.kind, "statusUnreadable");
87
+ assert.equal(current().isRunning, true);
88
+ assert.equal(current().isDone, false);
89
+ });
90
+
91
+ it("reports a create that never returned a job id as a start failure", async () => {
92
+ http = mockFetch(dropped);
93
+ harness = createDomHarness();
94
+ harness.renderApp(createElement(Probe));
95
+ await act(async () => {
96
+ current().start(DRAFT);
97
+ });
98
+ await settle();
99
+ assert.equal(current().failure?.kind, "startFailed");
100
+ assert.equal(current().isRunning, false);
101
+ });
102
+
103
+ it("re-polls the job it already has instead of starting a second one", async () => {
104
+ let readable = false;
105
+ await startJob(() =>
106
+ readable
107
+ ? {
108
+ organizeJobId: JOB,
109
+ state: "Running",
110
+ matchedCount: 1284,
111
+ appliedCount: 40,
112
+ failedCount: 0,
113
+ }
114
+ : dropped(),
115
+ );
116
+ assert.equal(current().failure?.kind, "statusUnreadable");
117
+ const pollsBefore = statusPolls();
118
+
119
+ readable = true;
120
+ await act(async () => {
121
+ current().refreshStatus();
122
+ });
123
+ await settle();
124
+
125
+ assert.equal(posts(), 1);
126
+ assert.ok(statusPolls() > pollsBefore, "the existing job was polled again");
127
+ assert.equal(current().failure, undefined);
128
+ assert.equal(current().progress.matchedCount, 1284);
129
+ });
130
+
131
+ it("stops reporting a job as running once it reaches a terminal state", async () => {
132
+ await startJob(() => ({
133
+ organizeJobId: JOB,
134
+ state: "Complete",
135
+ matchedCount: 1284,
136
+ appliedCount: 1284,
137
+ failedCount: 0,
138
+ }));
139
+ assert.equal(current().isDone, true);
140
+ assert.equal(current().failure, undefined);
141
+ });
142
+ });
@@ -22,6 +22,25 @@ export interface OrganizeJobProgress {
22
22
  errorMessage: string;
23
23
  }
24
24
 
25
+ /**
26
+ * Why the job is not reporting, which is two separate facts (#526). A create
27
+ * that never returned an id means nothing was started; a status read that
28
+ * failed means a job is out there and this client cannot see how far it got.
29
+ */
30
+ export interface OrganizeJobFailure {
31
+ kind: "startFailed" | "statusUnreadable";
32
+ error: unknown;
33
+ }
34
+
35
+ const organizeJobFailure = (
36
+ createError: unknown,
37
+ statusError: unknown,
38
+ ): OrganizeJobFailure | undefined => {
39
+ if (createError) return { kind: "startFailed", error: createError };
40
+ if (statusError) return { kind: "statusUnreadable", error: statusError };
41
+ return undefined;
42
+ };
43
+
25
44
  /**
26
45
  * "All like these" — start a one-time retroactive back-apply (POST /organize)
27
46
  * and poll its status to completion (GET /organize/{organizeJobId}). Polling
@@ -66,6 +85,13 @@ export const useOrganizeJob = (accountId: string | undefined) => {
66
85
  [accountId, createJob],
67
86
  );
68
87
 
88
+ const { refetch } = jobQuery;
89
+ // Looks at the job already in flight again. Distinct from `start`, which
90
+ // queues a second pass over the same mail.
91
+ const refreshStatus = useCallback(() => {
92
+ void refetch();
93
+ }, [refetch]);
94
+
69
95
  const job = jobQuery.data;
70
96
  const state = job?.state ?? createMutation.data?.state;
71
97
  const isDone = isTerminalJobState(job?.state);
@@ -80,11 +106,11 @@ export const useOrganizeJob = (accountId: string | undefined) => {
80
106
 
81
107
  return {
82
108
  start,
109
+ refreshStatus,
83
110
  progress,
84
111
  isStarting: createMutation.isPending,
85
112
  isRunning: !!organizeJobId && !isDone,
86
113
  isDone,
87
- isError: createMutation.isError || jobQuery.isError,
88
- error: createMutation.error ?? jobQuery.error,
114
+ failure: organizeJobFailure(createMutation.error, jobQuery.error),
89
115
  };
90
116
  };