@remit/web-client 0.0.240 → 0.0.242
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/auth/auth-interceptor.test.ts +41 -38
- package/src/components/compose/ComposeForm.tsx +51 -2
- package/src/components/compose/attachment-only-source.render.test.ts +233 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +7 -3
- package/src/components/ui/folder-role-refusal.test.ts +56 -26
- package/src/components/ui/folder-role-refusal.ts +6 -18
- package/src/components/ui/placement-refusal.test.ts +14 -6
- package/src/components/ui/placement-refusal.ts +5 -6
- package/src/hooks/refusal-escalation.render.test.ts +187 -0
- package/src/hooks/stale-account-sync-visibility.test.ts +166 -0
- package/src/hooks/use-system-update.test.ts +104 -0
- package/src/hooks/use-system-update.ts +6 -0
- package/src/hooks/useDeleteMessages.ts +5 -0
- package/src/hooks/useEmptyTrash.ts +4 -0
- package/src/hooks/useMessageBodyContent.ts +7 -0
- package/src/hooks/useStaleAccountSync.ts +16 -9
- package/src/lib/api.ts +42 -5
- package/src/lib/client.test.ts +68 -0
- package/src/lib/client.ts +2 -8
- package/src/routes/settings/folders.tsx +4 -0
- package/src/test-support/http.ts +3 -2
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
* appointment prompt over an unrelated conflict. A 409 without one of these
|
|
6
6
|
* codes is somebody else's error and keeps today's banner.
|
|
7
7
|
*/
|
|
8
|
-
import type {
|
|
9
|
-
ApiError,
|
|
10
|
-
RemitImapCanonicalMailboxRole,
|
|
11
|
-
} from "@remit/api-http-client/types.gen.ts";
|
|
8
|
+
import type { RemitImapCanonicalMailboxRole } from "@remit/api-http-client/types.gen.ts";
|
|
12
9
|
import { CanonicalMailboxRole } from "@remit/domain-enums";
|
|
10
|
+
import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
|
|
13
11
|
|
|
14
12
|
/** Why the role is unresolved, as the API's `details.reason` spells it. */
|
|
15
13
|
export type FolderRoleRefusalReason = "none" | "stale" | "unconfirmed";
|
|
@@ -29,18 +27,8 @@ const REASONS: ReadonlySet<string> = new Set<FolderRoleRefusalReason>([
|
|
|
29
27
|
|
|
30
28
|
const ROLES: ReadonlySet<string> = new Set(Object.values(CanonicalMailboxRole));
|
|
31
29
|
|
|
32
|
-
/**
|
|
33
|
-
* The wire body as `handleError` emits it — flat, so `code` and `details` sit
|
|
34
|
-
* at the top level. Everything is re-checked at runtime: this is a network
|
|
35
|
-
* boundary, and the type only says what the contract promises.
|
|
36
|
-
*/
|
|
37
|
-
const bodyOf = (error: unknown): Partial<ApiError> | undefined =>
|
|
38
|
-
typeof error === "object" && error !== null
|
|
39
|
-
? (error as Partial<ApiError>)
|
|
40
|
-
: undefined;
|
|
41
|
-
|
|
42
30
|
const stringAt = (
|
|
43
|
-
details:
|
|
31
|
+
details: CodedApiErrorBody["details"],
|
|
44
32
|
key: string,
|
|
45
33
|
): string | undefined => {
|
|
46
34
|
const value = details?.[key];
|
|
@@ -55,10 +43,10 @@ const stringAt = (
|
|
|
55
43
|
export const isFolderRoleRefusal = (
|
|
56
44
|
error: unknown,
|
|
57
45
|
): FolderRoleRefusal | undefined => {
|
|
58
|
-
const body =
|
|
46
|
+
const body = codedApiErrorBody(error);
|
|
59
47
|
if (body?.code !== "folder_role_unresolved") return undefined;
|
|
60
48
|
const { details } = body;
|
|
61
|
-
if (
|
|
49
|
+
if (!details) return undefined;
|
|
62
50
|
const reason = stringAt(details, "reason");
|
|
63
51
|
const role = stringAt(details, "role");
|
|
64
52
|
const accountId = stringAt(details, "accountId");
|
|
@@ -78,4 +66,4 @@ export const isFolderRoleRefusal = (
|
|
|
78
66
|
* one, retrying does not.
|
|
79
67
|
*/
|
|
80
68
|
export const isMailboxNotSettledRefusal = (error: unknown): boolean =>
|
|
81
|
-
|
|
69
|
+
codedApiErrorBody(error)?.code === "mailbox_not_settled";
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { wrapHttpFailure } from "@/lib/api";
|
|
3
4
|
import {
|
|
4
5
|
isPlacementRefusal,
|
|
5
6
|
placementRefusalBanner,
|
|
6
7
|
} from "./placement-refusal.js";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// The reader is handed what the error interceptor produces, not the flat wire
|
|
10
|
+
// body it carries at `.body` (#1004).
|
|
11
|
+
const refusal = (reason: string) =>
|
|
12
|
+
wrapHttpFailure(
|
|
13
|
+
{
|
|
14
|
+
message:
|
|
15
|
+
"Message 4f1 was not acted on: its folder and uid do not name...",
|
|
16
|
+
code: "message_placement_unsettled",
|
|
17
|
+
details: { accountId: "acc-1", messageId: "msg-1", reason },
|
|
18
|
+
},
|
|
19
|
+
409,
|
|
20
|
+
);
|
|
13
21
|
|
|
14
22
|
describe("isPlacementRefusal", () => {
|
|
15
23
|
it("reads the reason and the message off the coded body", () => {
|
|
@@ -23,7 +31,7 @@ describe("isPlacementRefusal", () => {
|
|
|
23
31
|
assert.equal(isPlacementRefusal(undefined), undefined);
|
|
24
32
|
assert.equal(isPlacementRefusal(new Error("network")), undefined);
|
|
25
33
|
assert.equal(
|
|
26
|
-
isPlacementRefusal({
|
|
34
|
+
isPlacementRefusal(wrapHttpFailure({ code: "other" }, 409)),
|
|
27
35
|
undefined,
|
|
28
36
|
);
|
|
29
37
|
assert.equal(isPlacementRefusal(refusal("something_else")), undefined);
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* server's sentence names a uuid and no remedy, and a message-string match
|
|
5
5
|
* would start firing on an unrelated conflict the moment the copy changes.
|
|
6
6
|
*/
|
|
7
|
-
import type { ApiError } from "@remit/api-http-client/types.gen.ts";
|
|
8
7
|
import type { PushErrorInput } from "@/components/ui/error-banners";
|
|
8
|
+
import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* `in_flight` clears on its own once the mail server confirms the move;
|
|
@@ -25,7 +25,7 @@ const REASONS: ReadonlySet<string> = new Set<PlacementRefusalReason>([
|
|
|
25
25
|
]);
|
|
26
26
|
|
|
27
27
|
const stringAt = (
|
|
28
|
-
details:
|
|
28
|
+
details: CodedApiErrorBody["details"],
|
|
29
29
|
key: string,
|
|
30
30
|
): string | undefined => {
|
|
31
31
|
const value = details?.[key];
|
|
@@ -35,11 +35,10 @@ const stringAt = (
|
|
|
35
35
|
export const isPlacementRefusal = (
|
|
36
36
|
error: unknown,
|
|
37
37
|
): PlacementRefusal | undefined => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (body.code !== "message_placement_unsettled") return undefined;
|
|
38
|
+
const body = codedApiErrorBody(error);
|
|
39
|
+
if (body?.code !== "message_placement_unsettled") return undefined;
|
|
41
40
|
const { details } = body;
|
|
42
|
-
if (
|
|
41
|
+
if (!details) return undefined;
|
|
43
42
|
const reason = stringAt(details, "reason");
|
|
44
43
|
const messageId = stringAt(details, "messageId");
|
|
45
44
|
if (!reason || !REASONS.has(reason) || !messageId) return undefined;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A coded 409 must reach the surface that answers it and nothing else (#1004).
|
|
3
|
+
*
|
|
4
|
+
* Both delete paths raise through `useMutation`, and the app wires the global
|
|
5
|
+
* fail-fast sink on the `MutationCache` (`shell/index.tsx`), where a 409 with
|
|
6
|
+
* no `meta` escalates to the full-screen fatal overlay. The prompt then opens
|
|
7
|
+
* underneath a red page nobody can see past — the refusal is handled and still
|
|
8
|
+
* never reaches the user. Every other test mounts on the harness's plain
|
|
9
|
+
* `QueryClient`, which has no such sink, so none of them can see this.
|
|
10
|
+
*
|
|
11
|
+
* Mounted here on the client the app actually builds, with the error
|
|
12
|
+
* interceptor registered.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { afterEach, describe, it } from "node:test";
|
|
17
|
+
import {
|
|
18
|
+
MutationCache,
|
|
19
|
+
QueryCache,
|
|
20
|
+
QueryClient,
|
|
21
|
+
QueryClientProvider,
|
|
22
|
+
} from "@tanstack/react-query";
|
|
23
|
+
import { act, createElement } from "react";
|
|
24
|
+
import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
25
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
26
|
+
import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
|
|
27
|
+
import {
|
|
28
|
+
handleMutationCacheError,
|
|
29
|
+
handleQueryCacheError,
|
|
30
|
+
} from "@/lib/query-error-handler";
|
|
31
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
32
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
33
|
+
import { useDeleteMessages } from "./useDeleteMessages";
|
|
34
|
+
import { type EmptyTrashState, useEmptyTrash } from "./useEmptyTrash";
|
|
35
|
+
import "@/lib/client";
|
|
36
|
+
|
|
37
|
+
const ACCOUNT = "acc-1";
|
|
38
|
+
const INBOX = "mbx-inbox";
|
|
39
|
+
const TRASH = "mbx-trash";
|
|
40
|
+
|
|
41
|
+
let harness: DomHarness | undefined;
|
|
42
|
+
let http: HttpMock | undefined;
|
|
43
|
+
let deleteMessages: ((messageIds: string[]) => void) | undefined;
|
|
44
|
+
let trash: EmptyTrashState | undefined;
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
harness?.close();
|
|
48
|
+
harness = undefined;
|
|
49
|
+
http?.restore();
|
|
50
|
+
http = undefined;
|
|
51
|
+
deleteMessages = undefined;
|
|
52
|
+
trash = undefined;
|
|
53
|
+
__resetFatalError();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const CONFIG = {
|
|
57
|
+
accounts: [
|
|
58
|
+
{
|
|
59
|
+
accountId: ACCOUNT,
|
|
60
|
+
email: "you@example.com",
|
|
61
|
+
folderAppointments: [
|
|
62
|
+
{ role: "Trash", mailboxId: TRASH, source: "Proposed" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Fresh every call — a `Response` body can only be read once. */
|
|
69
|
+
const refusal = (): Response =>
|
|
70
|
+
new Response(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
status: 409,
|
|
73
|
+
message: "Nobody has confirmed which folder is this account's Trash",
|
|
74
|
+
code: "folder_role_unresolved",
|
|
75
|
+
details: { reason: "unconfirmed", role: "Trash", accountId: ACCOUNT },
|
|
76
|
+
}),
|
|
77
|
+
{ status: 409, headers: { "content-type": "application/json" } },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const broken = (): Response =>
|
|
81
|
+
new Response(JSON.stringify({ message: "boom" }), {
|
|
82
|
+
status: 500,
|
|
83
|
+
headers: { "content-type": "application/json" },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const Probe = () => {
|
|
87
|
+
deleteMessages = useDeleteMessages({
|
|
88
|
+
mailboxId: INBOX,
|
|
89
|
+
accountId: ACCOUNT,
|
|
90
|
+
}).deleteMessages;
|
|
91
|
+
trash = useEmptyTrash({ accountId: ACCOUNT, mailboxId: TRASH });
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** The client `shell/index.tsx` builds: the real global escalation sink. */
|
|
96
|
+
const appQueryClient = (): QueryClient =>
|
|
97
|
+
new QueryClient({
|
|
98
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
99
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
100
|
+
defaultOptions: {
|
|
101
|
+
queries: { retry: false },
|
|
102
|
+
mutations: { retry: false },
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const settle = async (): Promise<void> => {
|
|
107
|
+
if (!harness) throw new Error("nothing mounted");
|
|
108
|
+
for (let round = 0; round < 40; round += 1) {
|
|
109
|
+
await harness.flush();
|
|
110
|
+
await harness.wait(0);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const mount = async (respond: (path: string) => unknown): Promise<void> => {
|
|
115
|
+
http = mockFetch((call) => respond(call.path));
|
|
116
|
+
harness = createDomHarness({ queryClient: appQueryClient() });
|
|
117
|
+
await harness.renderAsync(
|
|
118
|
+
createElement(
|
|
119
|
+
QueryClientProvider,
|
|
120
|
+
{ client: harness.queryClient as never },
|
|
121
|
+
createElement(
|
|
122
|
+
ErrorBannerProvider,
|
|
123
|
+
null,
|
|
124
|
+
createElement(
|
|
125
|
+
RoleAppointmentPromptProvider,
|
|
126
|
+
null,
|
|
127
|
+
createElement(Probe),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
await settle();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const responder =
|
|
136
|
+
(endpoint: string, answer: () => Response) =>
|
|
137
|
+
(path: string): unknown => {
|
|
138
|
+
if (path.endsWith("/config")) return CONFIG;
|
|
139
|
+
if (path.endsWith(endpoint)) return answer();
|
|
140
|
+
return {};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
describe("a delete refused for its folder role", () => {
|
|
144
|
+
it("opens the appointment prompt without taking the screen", async () => {
|
|
145
|
+
await mount(responder("/messages/delete", refusal));
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
deleteMessages?.(["msg-1"]);
|
|
149
|
+
});
|
|
150
|
+
await settle();
|
|
151
|
+
|
|
152
|
+
assert.match(harness?.text() ?? "", /Confirm this account's Trash folder/);
|
|
153
|
+
assert.equal(
|
|
154
|
+
getCurrentFatalError(),
|
|
155
|
+
null,
|
|
156
|
+
"a refusal the prompt answers is not a fatal error",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("still escalates a 500 on the same delete", async () => {
|
|
161
|
+
await mount(responder("/messages/delete", broken));
|
|
162
|
+
|
|
163
|
+
await act(async () => {
|
|
164
|
+
deleteMessages?.(["msg-1"]);
|
|
165
|
+
});
|
|
166
|
+
await settle();
|
|
167
|
+
|
|
168
|
+
assert.ok(
|
|
169
|
+
getCurrentFatalError(),
|
|
170
|
+
"the 409 opt-out must not cover anything else",
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("an empty Trash refused for its folder role", () => {
|
|
176
|
+
it("states the refusal in the pane without taking the screen", async () => {
|
|
177
|
+
await mount(responder("/trash/empty", refusal));
|
|
178
|
+
|
|
179
|
+
await act(async () => {
|
|
180
|
+
trash?.emptyTrash();
|
|
181
|
+
});
|
|
182
|
+
await settle();
|
|
183
|
+
|
|
184
|
+
assert.equal(trash?.refusal?.reason, "unconfirmed");
|
|
185
|
+
assert.equal(getCurrentFatalError(), null);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coming back to a tab always leaves the poll loop with a next tick (#1031).
|
|
3
|
+
*
|
|
4
|
+
* A tick that fires while the tab is hidden deliberately does not reschedule —
|
|
5
|
+
* `visibilitychange` is the only thing that resumes the loop. While that resume
|
|
6
|
+
* was itself conditional on a poll being due, the resume could be skipped for
|
|
7
|
+
* good: press refresh (which opens the hot 30s window), hide the tab shortly
|
|
8
|
+
* after a hot tick, let the window lapse while away, and the return is measured
|
|
9
|
+
* against the ambient interval the tab never slept through. Nothing re-armed,
|
|
10
|
+
* and the tab stopped polling until the account list changed or the layout
|
|
11
|
+
* remounted.
|
|
12
|
+
*
|
|
13
|
+
* At the default the ambient and hot intervals are equal and the loop is safe
|
|
14
|
+
* either way, so these drive a deployment that lengthened
|
|
15
|
+
* `mailboxPollIntervalSeconds` — the only shape where the bug is reachable.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { afterEach, beforeEach, describe, mock, test } from "node:test";
|
|
20
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
21
|
+
import { act, createElement } from "react";
|
|
22
|
+
import {
|
|
23
|
+
__resetHotSyncWindow,
|
|
24
|
+
HOT_POLL_INTERVAL_MS,
|
|
25
|
+
HOT_WINDOW_MS,
|
|
26
|
+
startHotSyncWindow,
|
|
27
|
+
} from "@/lib/hot-sync-window";
|
|
28
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
29
|
+
import { makeAccount } from "@/test-support/fixtures";
|
|
30
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
31
|
+
|
|
32
|
+
const ACCOUNT_ID = "acc-1";
|
|
33
|
+
const AMBIENT_SECONDS = 600;
|
|
34
|
+
const AMBIENT_MS = AMBIENT_SECONDS * 1000;
|
|
35
|
+
const T0 = 1_700_000_000_000;
|
|
36
|
+
|
|
37
|
+
// The hook resolves the ambient interval once, at module load, so the config has
|
|
38
|
+
// to be in place before it is imported.
|
|
39
|
+
globalThis.__REMIT_CONFIG__ = {
|
|
40
|
+
mailboxPollIntervalSeconds: String(AMBIENT_SECONDS),
|
|
41
|
+
};
|
|
42
|
+
const { __resetStaleAccountSyncGuard, POLL_INTERVAL_MS, useStaleAccountSync } =
|
|
43
|
+
await import("./useStaleAccountSync.js");
|
|
44
|
+
|
|
45
|
+
let harness: DomHarness | undefined;
|
|
46
|
+
let http: HttpMock | undefined;
|
|
47
|
+
|
|
48
|
+
function PollLoop() {
|
|
49
|
+
useStaleAccountSync([makeAccount({ accountId: ACCOUNT_ID, lastSyncAt: T0 })]);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* jsdom reports "prerender" unless it is pretending to be visual, and the
|
|
55
|
+
* shared environment deliberately does not — so a spec about a tab being looked
|
|
56
|
+
* at has to say so.
|
|
57
|
+
*/
|
|
58
|
+
const setVisibility = (state: "hidden" | "visible"): void => {
|
|
59
|
+
Object.defineProperty(document, "visibilityState", {
|
|
60
|
+
configurable: true,
|
|
61
|
+
get: () => state,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Enough microtask turns for a dispatched poll to reach the fetch seam. */
|
|
66
|
+
const settle = async (dom: DomHarness): Promise<void> => {
|
|
67
|
+
for (let round = 0; round < 3; round += 1) await dom.flush();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const mount = async (): Promise<DomHarness> => {
|
|
71
|
+
http = mockFetch(() => ({ status: "queued" }));
|
|
72
|
+
const dom = createDomHarness();
|
|
73
|
+
harness = dom;
|
|
74
|
+
dom.render(
|
|
75
|
+
createElement(
|
|
76
|
+
QueryClientProvider,
|
|
77
|
+
{ client: dom.queryClient },
|
|
78
|
+
createElement(PollLoop),
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
await settle(dom);
|
|
82
|
+
return dom;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const advance = async (dom: DomHarness, ms: number): Promise<void> => {
|
|
86
|
+
await act(async () => {
|
|
87
|
+
mock.timers.tick(ms);
|
|
88
|
+
});
|
|
89
|
+
await settle(dom);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const goVisible = async (dom: DomHarness): Promise<void> => {
|
|
93
|
+
setVisibility("visible");
|
|
94
|
+
dom.dispatch(dom.document, new Event("visibilitychange"));
|
|
95
|
+
await settle(dom);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const goHidden = async (dom: DomHarness): Promise<void> => {
|
|
99
|
+
setVisibility("hidden");
|
|
100
|
+
dom.dispatch(dom.document, new Event("visibilitychange"));
|
|
101
|
+
await settle(dom);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Every background sync this tab has fired since it mounted. */
|
|
105
|
+
const polls = (): number => http?.to(`/${ACCOUNT_ID}/sync`).length ?? 0;
|
|
106
|
+
|
|
107
|
+
describe("the poll loop across hide and show, on a long ambient interval", () => {
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
mock.timers.enable({ apis: ["setTimeout", "Date"], now: T0 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
mock.timers.reset();
|
|
114
|
+
harness?.close();
|
|
115
|
+
harness = undefined;
|
|
116
|
+
http?.restore();
|
|
117
|
+
http = undefined;
|
|
118
|
+
__resetHotSyncWindow();
|
|
119
|
+
__resetStaleAccountSyncGuard();
|
|
120
|
+
Reflect.deleteProperty(document, "visibilityState");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("the deployment under test really is on a lengthened interval", () => {
|
|
124
|
+
assert.equal(POLL_INTERVAL_MS, AMBIENT_MS);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("keeps polling after a refresh press, a hidden hot window and a return", async () => {
|
|
128
|
+
setVisibility("visible");
|
|
129
|
+
const dom = await mount();
|
|
130
|
+
|
|
131
|
+
// The press: the hot window opens and the loop drops onto its cadence.
|
|
132
|
+
startHotSyncWindow();
|
|
133
|
+
await advance(dom, HOT_POLL_INTERVAL_MS);
|
|
134
|
+
assert.equal(polls(), 1, "the hot cadence polled once");
|
|
135
|
+
|
|
136
|
+
// Away before the next hot tick, and back only after the window lapsed.
|
|
137
|
+
await goHidden(dom);
|
|
138
|
+
await advance(dom, HOT_WINDOW_MS);
|
|
139
|
+
assert.equal(polls(), 1, "a hidden tab polled");
|
|
140
|
+
|
|
141
|
+
await goVisible(dom);
|
|
142
|
+
assert.equal(
|
|
143
|
+
polls(),
|
|
144
|
+
1,
|
|
145
|
+
"the return polled a tab that is not due on the ambient interval",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await advance(dom, AMBIENT_MS);
|
|
149
|
+
assert.equal(polls(), 2, "the tab came back with no tick scheduled");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("polls on return once the ambient interval has passed", async () => {
|
|
153
|
+
setVisibility("visible");
|
|
154
|
+
const dom = await mount();
|
|
155
|
+
|
|
156
|
+
await goHidden(dom);
|
|
157
|
+
await advance(dom, AMBIENT_MS);
|
|
158
|
+
assert.equal(polls(), 0, "a hidden tab polled");
|
|
159
|
+
|
|
160
|
+
await goVisible(dom);
|
|
161
|
+
assert.equal(polls(), 1, "a tab due a poll did not catch up on return");
|
|
162
|
+
|
|
163
|
+
await advance(dom, AMBIENT_MS);
|
|
164
|
+
assert.equal(polls(), 2, "the catch-up left no next tick behind it");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -558,6 +558,110 @@ describe("useSystemUpdate — actions", () => {
|
|
|
558
558
|
}
|
|
559
559
|
});
|
|
560
560
|
|
|
561
|
+
test("checking from the success row reaches the pane, not a dead button (#1015)", async () => {
|
|
562
|
+
// An undismissed run outcome wins over any check in flight, so a press
|
|
563
|
+
// made while the success banner holds the pane would leave the screen
|
|
564
|
+
// exactly as it was. The row's control retires the outcome first.
|
|
565
|
+
const succeeded = {
|
|
566
|
+
currentVersion: "0.9.4",
|
|
567
|
+
check: { status: "ok", updateAvailable: false },
|
|
568
|
+
run: run({ outcome: "succeeded" }),
|
|
569
|
+
};
|
|
570
|
+
const { dom } = mountApi(() => succeeded, createElement(SelfUpdatePanel));
|
|
571
|
+
await settle(dom);
|
|
572
|
+
assert.match(dom.html(), /Updated to Remit 0\.9\.4/);
|
|
573
|
+
|
|
574
|
+
dom.click(dom.byText("button", "Check for updates"));
|
|
575
|
+
await waitFor(
|
|
576
|
+
dom,
|
|
577
|
+
() => /Looking for a newer version/.test(dom.html()),
|
|
578
|
+
"the pressed check to reach the pane",
|
|
579
|
+
);
|
|
580
|
+
assert.doesNotMatch(dom.html(), /Updated to Remit/);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("a check pressed from the success row fails in the open, not behind the banner (#1015)", async () => {
|
|
584
|
+
const succeeded = {
|
|
585
|
+
currentVersion: "0.9.4",
|
|
586
|
+
check: { status: "ok", updateAvailable: false },
|
|
587
|
+
run: run({ outcome: "succeeded" }),
|
|
588
|
+
};
|
|
589
|
+
const { dom } = mountApi(() => succeeded, createElement(SelfUpdatePanel));
|
|
590
|
+
await settle(dom);
|
|
591
|
+
|
|
592
|
+
const control = dom.byText("button", "Check for updates");
|
|
593
|
+
const realNow = Date.now;
|
|
594
|
+
try {
|
|
595
|
+
await act(async () => {
|
|
596
|
+
// Dispatched inside this `act` rather than through `dom.click`, so
|
|
597
|
+
// the press is stamped off the real clock and the wait it arms is
|
|
598
|
+
// measured against one already past the budget.
|
|
599
|
+
control.dispatchEvent(
|
|
600
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
601
|
+
);
|
|
602
|
+
Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
|
|
603
|
+
await dom.flush();
|
|
604
|
+
});
|
|
605
|
+
await dom.wait(5);
|
|
606
|
+
await dom.flush();
|
|
607
|
+
|
|
608
|
+
assert.match(dom.html(), /The updater did not answer/);
|
|
609
|
+
assert.doesNotMatch(dom.html(), /Updated to Remit/);
|
|
610
|
+
} finally {
|
|
611
|
+
Date.now = realNow;
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("dismissing an outcome does not uncover the check verdict it hid", async () => {
|
|
616
|
+
// The failure belongs to a press made before the run, on a pane the user
|
|
617
|
+
// has not been able to see since. Serving it as the current verdict claims
|
|
618
|
+
// a check failed that may well have succeeded.
|
|
619
|
+
let response: unknown = available;
|
|
620
|
+
const { dom, api } = mountApi(
|
|
621
|
+
() => response,
|
|
622
|
+
createElement(SelfUpdatePanel),
|
|
623
|
+
);
|
|
624
|
+
await settle(dom);
|
|
625
|
+
|
|
626
|
+
const realNow = Date.now;
|
|
627
|
+
try {
|
|
628
|
+
await act(async () => {
|
|
629
|
+
api().onCheck();
|
|
630
|
+
Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
|
|
631
|
+
await dom.flush();
|
|
632
|
+
});
|
|
633
|
+
await dom.wait(5);
|
|
634
|
+
await dom.flush();
|
|
635
|
+
assert.match(dom.html(), /The updater did not answer/);
|
|
636
|
+
} finally {
|
|
637
|
+
Date.now = realNow;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
response = {
|
|
641
|
+
currentVersion: "0.9.4",
|
|
642
|
+
check: { status: "ok", updateAvailable: false },
|
|
643
|
+
run: run({ outcome: "succeeded" }),
|
|
644
|
+
};
|
|
645
|
+
await retryConnection(dom, api);
|
|
646
|
+
await waitFor(
|
|
647
|
+
dom,
|
|
648
|
+
() => /Updated to Remit 0\.9\.4/.test(dom.html()),
|
|
649
|
+
"the finished run to reach the pane",
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
await act(async () => {
|
|
653
|
+
api().onDismissResult();
|
|
654
|
+
await dom.flush();
|
|
655
|
+
});
|
|
656
|
+
await waitFor(
|
|
657
|
+
dom,
|
|
658
|
+
() => !/Updated to Remit/.test(dom.html()),
|
|
659
|
+
"the outcome to be retired",
|
|
660
|
+
);
|
|
661
|
+
assert.doesNotMatch(dom.html(), /The updater did not answer/);
|
|
662
|
+
assert.match(dom.html(), /0\.9\.4 is the latest version/);
|
|
663
|
+
});
|
|
664
|
+
|
|
561
665
|
test("a refused refresh is shown, never swallowed back into the old verdict (#599)", async () => {
|
|
562
666
|
let refused = false;
|
|
563
667
|
const { dom, api } = mountApi(
|
|
@@ -249,6 +249,12 @@ export function useSystemUpdate(): SelfUpdateApi {
|
|
|
249
249
|
if (shownRunIdRef.current !== null)
|
|
250
250
|
setDismissedRunId(shownRunIdRef.current);
|
|
251
251
|
setHeld(null);
|
|
252
|
+
// Retiring the outcome uncovers the check verdict beneath it. A failure
|
|
253
|
+
// recorded while the outcome held the pane describes a press the user can
|
|
254
|
+
// no longer see, and an update run of its own supersedes anything an
|
|
255
|
+
// earlier check concluded — either way it must not resurface here.
|
|
256
|
+
setCheckPress(null);
|
|
257
|
+
setCheckFailure(null);
|
|
252
258
|
}, []);
|
|
253
259
|
|
|
254
260
|
return {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@/components/ui/placement-refusal";
|
|
17
17
|
import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
|
|
18
18
|
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
19
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
19
20
|
import {
|
|
20
21
|
cancelThreadListQueries,
|
|
21
22
|
invalidateThreadListQueries,
|
|
@@ -92,6 +93,10 @@ export const useDeleteMessages = ({
|
|
|
92
93
|
|
|
93
94
|
const { mutateAsync, isPending } = useMutation({
|
|
94
95
|
...messageBulkOperationsDeleteMessagesMutation(),
|
|
96
|
+
// A coded 409 is answered here — the prompt, or a banner — so it must not
|
|
97
|
+
// also take the whole screen. Only 409: a 401, 403 or 5xx on a delete is
|
|
98
|
+
// still the fatal page's (#1059).
|
|
99
|
+
meta: softErrorStatuses(409),
|
|
95
100
|
onMutate: async (variables): Promise<ThreadMutationContext> => {
|
|
96
101
|
const messageIds = new Set(variables.body.messageIds ?? []);
|
|
97
102
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type FolderRoleRefusal,
|
|
23
23
|
isFolderRoleRefusal,
|
|
24
24
|
} from "@/components/ui/folder-role-refusal";
|
|
25
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
25
26
|
import {
|
|
26
27
|
invalidateThreadListQueries,
|
|
27
28
|
threadListCacheKeys,
|
|
@@ -109,6 +110,9 @@ export const useEmptyTrash = ({
|
|
|
109
110
|
|
|
110
111
|
const { mutateAsync, isPending } = useMutation({
|
|
111
112
|
...trashOperationsEmptyTrashMutation(),
|
|
113
|
+
// The refusal is rendered in the pane, so it must not also take the whole
|
|
114
|
+
// screen. Only 409 — every other status is still the fatal page's (#1059).
|
|
115
|
+
meta: softErrorStatuses(409),
|
|
112
116
|
onMutate: (): EmptyTrashContext => {
|
|
113
117
|
inFlight.current = scope;
|
|
114
118
|
return { scope, listMailboxId: listMailboxIdNow() };
|
|
@@ -351,6 +351,12 @@ export const useMessageBodyContent = ({
|
|
|
351
351
|
|
|
352
352
|
const isLoading = query.isLoading && !!picked;
|
|
353
353
|
|
|
354
|
+
// `picked` is null for two different reasons, and a caller that treats them
|
|
355
|
+
// alike is wrong about one of them: the message has not arrived yet, or it
|
|
356
|
+
// arrived carrying nothing renderable. Only the second is a fact about the
|
|
357
|
+
// message, and only it may be stated to the reader (#1030).
|
|
358
|
+
const hasNoRenderablePart = bodyParts !== undefined && picked === null;
|
|
359
|
+
|
|
354
360
|
useEffect(() => {
|
|
355
361
|
if (isLoading) {
|
|
356
362
|
loadStartRef.current = performance.now();
|
|
@@ -374,6 +380,7 @@ export const useMessageBodyContent = ({
|
|
|
374
380
|
picked,
|
|
375
381
|
data,
|
|
376
382
|
isLoading,
|
|
383
|
+
hasNoRenderablePart,
|
|
377
384
|
isError: query.isError,
|
|
378
385
|
error: query.error,
|
|
379
386
|
refetch: query.refetch,
|