@remit/web-client 0.0.106 → 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.106",
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
+ };
@@ -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
  };