@remit/web-client 0.0.60 → 0.0.62
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/components/mail/AutoMovedIndicator.tsx +2 -1
- package/src/components/ui/AppVersion.tsx +16 -9
- package/src/hooks/useAutoMovedBadge.ts +40 -9
- package/src/lib/app-info.ts +13 -1
- package/src/lib/auto-moved.test.ts +135 -49
- package/src/lib/auto-moved.ts +45 -19
- package/src/lib/bug-report.test.ts +144 -1
- package/src/lib/bug-report.ts +80 -4
- package/src/lib/network-error.ts +17 -1
- package/src/lib/request-breadcrumbs.test.ts +162 -0
- package/src/lib/request-breadcrumbs.ts +138 -0
- package/src/lib/route-breadcrumbs.test.ts +41 -0
- package/src/lib/route-breadcrumbs.ts +45 -0
- package/src/router.tsx +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.62",
|
|
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": {
|
|
@@ -8,7 +8,7 @@ interface AutoMovedIndicatorProps {
|
|
|
8
8
|
threadId: string;
|
|
9
9
|
mailboxId: string;
|
|
10
10
|
autoMoved: RemitImapAutoMovedInfo | undefined;
|
|
11
|
-
/** `md` (reading view) adds the inline Undo
|
|
11
|
+
/** `md` (reading view) adds the inline actions (Undo, and the Manage-filter link for a filter move); `sm` (list row) shows the icon + label. */
|
|
12
12
|
size?: "sm" | "md";
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -43,6 +43,7 @@ export function AutoMovedIndicator({
|
|
|
43
43
|
size={size}
|
|
44
44
|
onUndo={badge.onUndo}
|
|
45
45
|
undoLabel={badge.isUndoing ? "Undoing…" : "Undo"}
|
|
46
|
+
filtersHref={size === "md" ? badge.filtersHref : undefined}
|
|
46
47
|
/>
|
|
47
48
|
);
|
|
48
49
|
}
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
export interface AppVersionProps {
|
|
8
8
|
/** Override SHA for testing/Storybook. Defaults to the build-time constant. */
|
|
9
9
|
sha?: string;
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Override commit URL. Defaults to the GitHub commit link, which is undefined
|
|
12
|
+
* for a local "dev" build with no real SHA — the version renders unlinked then.
|
|
13
|
+
*/
|
|
11
14
|
commitUrl?: string;
|
|
12
15
|
/** Override build time ISO string. Defaults to the build-time constant. */
|
|
13
16
|
buildTime?: string;
|
|
@@ -35,14 +38,18 @@ export function AppVersion({
|
|
|
35
38
|
return (
|
|
36
39
|
<p className="text-xs text-fg-subtle">
|
|
37
40
|
Version{" "}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
{commitUrl ? (
|
|
42
|
+
<a
|
|
43
|
+
href={commitUrl}
|
|
44
|
+
target="_blank"
|
|
45
|
+
rel="noopener noreferrer"
|
|
46
|
+
className="font-mono hover:text-fg-muted hover:underline"
|
|
47
|
+
>
|
|
48
|
+
{sha}
|
|
49
|
+
</a>
|
|
50
|
+
) : (
|
|
51
|
+
<span className="font-mono">{sha}</span>
|
|
52
|
+
)}
|
|
46
53
|
{" · "}
|
|
47
54
|
<span>Built {formatBuildTime(buildTime)}</span>
|
|
48
55
|
</p>
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
1
2
|
import type { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
4
|
import { useCallback } from "react";
|
|
3
5
|
import {
|
|
4
6
|
autoMovedLabel,
|
|
5
7
|
isAutoMoveInEffect,
|
|
6
8
|
resolveUndoTargetMailboxId,
|
|
7
9
|
} from "@/lib/auto-moved";
|
|
10
|
+
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
8
11
|
import { useInboxMailbox, useJunkMailbox } from "./useArchiveMailbox";
|
|
9
12
|
import { useMoveMessages } from "./useMoveMessages";
|
|
10
13
|
|
|
@@ -24,14 +27,26 @@ export interface AutoMovedBadgeState {
|
|
|
24
27
|
/** Present only when the undo target mailbox resolved. */
|
|
25
28
|
onUndo?: () => void;
|
|
26
29
|
isUndoing: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Settings › Filters link, present only for a standing-filter move — undo
|
|
32
|
+
* returns the message but never disables the filter, so the badge points to
|
|
33
|
+
* where the filter can be managed.
|
|
34
|
+
*/
|
|
35
|
+
filtersHref?: string;
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
/**
|
|
30
|
-
* Composes the account's
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
39
|
+
* Composes the account's mailboxes with the message's `autoMoved` projection
|
|
40
|
+
* into everything the `AutoMovedBadge` kit component needs: the derived "still
|
|
41
|
+
* in effect" gate, the plain-language label, and a one-click undo bound to the
|
|
42
|
+
* existing `moveMessages` mutation (no new endpoint — moves back through the
|
|
43
|
+
* same bulk move operation, the other direction).
|
|
44
|
+
*
|
|
45
|
+
* Both auto-move shapes are handled. A classifier move resolves its
|
|
46
|
+
* Inbox/Junk role mailboxes; a standing-filter move names an arbitrary source
|
|
47
|
+
* folder, whose display name is resolved from the account's mailbox list, and
|
|
48
|
+
* carries a Settings › Filters link so the filter that keeps moving mail is one
|
|
49
|
+
* tap away — undo does not disable it.
|
|
35
50
|
*
|
|
36
51
|
* `show` re-derives on every render from `mailboxId` — no local dismissed
|
|
37
52
|
* flag. Once `moveMessages` settles, its query invalidation refetches the
|
|
@@ -53,11 +68,20 @@ export const useAutoMovedBadge = ({
|
|
|
53
68
|
accountId,
|
|
54
69
|
});
|
|
55
70
|
|
|
71
|
+
const { data: mailboxes } = useQuery({
|
|
72
|
+
...mailboxOperationsListMailboxesOptions({
|
|
73
|
+
path: { accountId: accountId ?? "" },
|
|
74
|
+
}),
|
|
75
|
+
staleTime: Infinity,
|
|
76
|
+
enabled: !!accountId && autoMoved?.fromMailboxId !== undefined,
|
|
77
|
+
});
|
|
78
|
+
|
|
56
79
|
const roleMailboxes = { inboxMailboxId, junkMailboxId };
|
|
57
80
|
const show = isAutoMoveInEffect(autoMoved, mailboxId, roleMailboxes);
|
|
58
|
-
const undoTargetMailboxId =
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
const undoTargetMailboxId = resolveUndoTargetMailboxId(
|
|
82
|
+
autoMoved,
|
|
83
|
+
roleMailboxes,
|
|
84
|
+
);
|
|
61
85
|
|
|
62
86
|
const handleUndo = useCallback(() => {
|
|
63
87
|
if (!undoTargetMailboxId) return;
|
|
@@ -68,10 +92,17 @@ export const useAutoMovedBadge = ({
|
|
|
68
92
|
return { show: false, label: "", isUndoing: false };
|
|
69
93
|
}
|
|
70
94
|
|
|
95
|
+
const sourceFolderName = autoMoved.fromMailboxId
|
|
96
|
+
? mailboxes?.items
|
|
97
|
+
.filter((mailbox) => mailbox.mailboxId === autoMoved.fromMailboxId)
|
|
98
|
+
.map((mailbox) => getMailboxDisplayName(mailbox.fullPath))[0]
|
|
99
|
+
: undefined;
|
|
100
|
+
|
|
71
101
|
return {
|
|
72
102
|
show: true,
|
|
73
|
-
label: autoMovedLabel(autoMoved
|
|
103
|
+
label: autoMovedLabel(autoMoved, sourceFolderName),
|
|
74
104
|
onUndo: undoTargetMailboxId ? handleUndo : undefined,
|
|
75
105
|
isUndoing: isPending,
|
|
106
|
+
...(autoMoved.filterId ? { filtersHref: "/settings/filters" } : {}),
|
|
76
107
|
};
|
|
77
108
|
};
|
package/src/lib/app-info.ts
CHANGED
|
@@ -10,6 +10,18 @@ export const APP_BUILD_TIME: string = __APP_BUILD_TIME__;
|
|
|
10
10
|
/** First 7 characters of the SHA, matching git's default short form. */
|
|
11
11
|
export const APP_SHORT_SHA: string = APP_SHA.slice(0, 7);
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* True when `APP_SHA` is a real git commit — a build that was given the commit
|
|
15
|
+
* (via `GITHUB_SHA` at build time). A local build with no git resolves to the
|
|
16
|
+
* literal "dev", which is not a commit and must not be linked (a
|
|
17
|
+
* `/commit/dev` URL is a dead link).
|
|
18
|
+
*/
|
|
19
|
+
export const APP_SHA_IS_COMMIT = /^[0-9a-f]{40}$/.test(APP_SHA);
|
|
20
|
+
|
|
21
|
+
/** Commit URL for the build, or undefined when the build has no real SHA. */
|
|
22
|
+
export const GITHUB_COMMIT_URL: string | undefined = APP_SHA_IS_COMMIT
|
|
23
|
+
? `https://github.com/remit-mail/reader/commit/${APP_SHA}`
|
|
24
|
+
: undefined;
|
|
25
|
+
|
|
14
26
|
export const GITHUB_NEW_ISSUE_URL =
|
|
15
27
|
"https://github.com/remit-mail/reader/issues/new";
|
|
@@ -14,23 +14,65 @@ const ROLE_MAILBOXES: AutoMovedRoleMailboxes = {
|
|
|
14
14
|
junkMailboxId: "mb-junk",
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
const classifierMove = (
|
|
18
|
+
action: RemitImapAutoMovedInfo["action"],
|
|
19
|
+
fromPlacement: string,
|
|
20
|
+
): RemitImapAutoMovedInfo => ({ action, fromPlacement });
|
|
21
|
+
|
|
22
|
+
const filterMove = (
|
|
23
|
+
fromMailboxId: string,
|
|
24
|
+
destinationMailboxId: string,
|
|
25
|
+
): RemitImapAutoMovedInfo => ({
|
|
26
|
+
fromMailboxId,
|
|
27
|
+
destinationMailboxId,
|
|
28
|
+
filterId: "flt-1",
|
|
29
|
+
});
|
|
30
|
+
|
|
17
31
|
describe("autoMovedLabel", () => {
|
|
18
|
-
test("reads 'Moved from Junk by Remit'", () => {
|
|
19
|
-
assert.equal(
|
|
32
|
+
test("classifier move reads 'Moved from Junk by Remit'", () => {
|
|
33
|
+
assert.equal(
|
|
34
|
+
autoMovedLabel(classifierMove(PlacementAction.MoveToInbox, "junk")),
|
|
35
|
+
"Moved from Junk by Remit",
|
|
36
|
+
);
|
|
20
37
|
});
|
|
21
38
|
|
|
22
|
-
test("reads 'Moved from Inbox by Remit'", () => {
|
|
23
|
-
assert.equal(
|
|
39
|
+
test("classifier move reads 'Moved from Inbox by Remit'", () => {
|
|
40
|
+
assert.equal(
|
|
41
|
+
autoMovedLabel(classifierMove(PlacementAction.MoveToJunk, "inbox")),
|
|
42
|
+
"Moved from Inbox by Remit",
|
|
43
|
+
);
|
|
24
44
|
});
|
|
25
45
|
|
|
26
|
-
test("falls back to plain language for an unrecognized placement", () => {
|
|
27
|
-
assert.equal(
|
|
46
|
+
test("classifier move falls back to plain language for an unrecognized placement", () => {
|
|
47
|
+
assert.equal(
|
|
48
|
+
autoMovedLabel(classifierMove(PlacementAction.MoveToInbox, "other")),
|
|
49
|
+
"Moved from another folder by Remit",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("filter move names the resolved source folder", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
autoMovedLabel(filterMove("mb-inbox", "mb-travel"), "Travel"),
|
|
56
|
+
"Moved from Travel by Remit",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("filter move falls back to 'another folder' before the name resolves", () => {
|
|
61
|
+
assert.equal(
|
|
62
|
+
autoMovedLabel(filterMove("mb-inbox", "mb-travel")),
|
|
63
|
+
"Moved from another folder by Remit",
|
|
64
|
+
);
|
|
28
65
|
});
|
|
29
66
|
|
|
30
67
|
test("never leaks verdict jargon", () => {
|
|
31
|
-
|
|
68
|
+
const cases = [
|
|
69
|
+
classifierMove(PlacementAction.MoveToInbox, "junk"),
|
|
70
|
+
classifierMove(PlacementAction.MoveToJunk, "inbox"),
|
|
71
|
+
filterMove("mb-inbox", "mb-travel"),
|
|
72
|
+
];
|
|
73
|
+
for (const autoMoved of cases) {
|
|
32
74
|
assert.doesNotMatch(
|
|
33
|
-
autoMovedLabel(
|
|
75
|
+
autoMovedLabel(autoMoved),
|
|
34
76
|
/confiden|dry.?run|verdict/i,
|
|
35
77
|
);
|
|
36
78
|
}
|
|
@@ -46,94 +88,138 @@ describe("isAutoMoveInEffect", () => {
|
|
|
46
88
|
});
|
|
47
89
|
|
|
48
90
|
test("false when currentMailboxId is absent", () => {
|
|
49
|
-
const autoMoved: RemitImapAutoMovedInfo = {
|
|
50
|
-
action: PlacementAction.MoveToInbox,
|
|
51
|
-
fromPlacement: "junk",
|
|
52
|
-
};
|
|
53
91
|
assert.equal(
|
|
54
|
-
isAutoMoveInEffect(
|
|
92
|
+
isAutoMoveInEffect(
|
|
93
|
+
classifierMove(PlacementAction.MoveToInbox, "junk"),
|
|
94
|
+
undefined,
|
|
95
|
+
ROLE_MAILBOXES,
|
|
96
|
+
),
|
|
55
97
|
false,
|
|
56
98
|
);
|
|
57
99
|
});
|
|
58
100
|
|
|
59
|
-
test("true when a MoveToInbox message
|
|
60
|
-
const autoMoved: RemitImapAutoMovedInfo = {
|
|
61
|
-
action: PlacementAction.MoveToInbox,
|
|
62
|
-
fromPlacement: "junk",
|
|
63
|
-
};
|
|
101
|
+
test("classifier: true when a MoveToInbox message sits in the Inbox mailbox", () => {
|
|
64
102
|
assert.equal(
|
|
65
|
-
isAutoMoveInEffect(
|
|
103
|
+
isAutoMoveInEffect(
|
|
104
|
+
classifierMove(PlacementAction.MoveToInbox, "junk"),
|
|
105
|
+
"mb-inbox",
|
|
106
|
+
ROLE_MAILBOXES,
|
|
107
|
+
),
|
|
66
108
|
true,
|
|
67
109
|
);
|
|
68
110
|
});
|
|
69
111
|
|
|
70
|
-
test("true when a MoveToJunk message
|
|
71
|
-
const autoMoved: RemitImapAutoMovedInfo = {
|
|
72
|
-
action: PlacementAction.MoveToJunk,
|
|
73
|
-
fromPlacement: "inbox",
|
|
74
|
-
};
|
|
112
|
+
test("classifier: true when a MoveToJunk message sits in the Junk mailbox", () => {
|
|
75
113
|
assert.equal(
|
|
76
|
-
isAutoMoveInEffect(
|
|
114
|
+
isAutoMoveInEffect(
|
|
115
|
+
classifierMove(PlacementAction.MoveToJunk, "inbox"),
|
|
116
|
+
"mb-junk",
|
|
117
|
+
ROLE_MAILBOXES,
|
|
118
|
+
),
|
|
77
119
|
true,
|
|
78
120
|
);
|
|
79
121
|
});
|
|
80
122
|
|
|
81
|
-
test("false once the message has moved elsewhere
|
|
82
|
-
const autoMoved
|
|
83
|
-
action: PlacementAction.MoveToInbox,
|
|
84
|
-
fromPlacement: "junk",
|
|
85
|
-
};
|
|
123
|
+
test("classifier: false once the message has moved elsewhere", () => {
|
|
124
|
+
const autoMoved = classifierMove(PlacementAction.MoveToInbox, "junk");
|
|
86
125
|
assert.equal(
|
|
87
126
|
isAutoMoveInEffect(autoMoved, "mb-archive", ROLE_MAILBOXES),
|
|
88
127
|
false,
|
|
89
128
|
);
|
|
90
|
-
// Undone back to Junk — no longer in the implied destination.
|
|
91
129
|
assert.equal(
|
|
92
130
|
isAutoMoveInEffect(autoMoved, "mb-junk", ROLE_MAILBOXES),
|
|
93
131
|
false,
|
|
94
132
|
);
|
|
95
133
|
});
|
|
96
134
|
|
|
97
|
-
test("
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
135
|
+
test("filter: true while the message sits in the filter's destination", () => {
|
|
136
|
+
assert.equal(
|
|
137
|
+
isAutoMoveInEffect(
|
|
138
|
+
filterMove("mb-inbox", "mb-travel"),
|
|
139
|
+
"mb-travel",
|
|
140
|
+
ROLE_MAILBOXES,
|
|
141
|
+
),
|
|
142
|
+
true,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("filter: false once undone back to the source folder", () => {
|
|
147
|
+
assert.equal(
|
|
148
|
+
isAutoMoveInEffect(
|
|
149
|
+
filterMove("mb-inbox", "mb-travel"),
|
|
150
|
+
"mb-inbox",
|
|
151
|
+
ROLE_MAILBOXES,
|
|
152
|
+
),
|
|
153
|
+
false,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("filter: in-effect does not depend on the Inbox/Junk role mailboxes", () => {
|
|
102
158
|
assert.equal(
|
|
103
|
-
isAutoMoveInEffect(
|
|
159
|
+
isAutoMoveInEffect(filterMove("mb-inbox", "mb-travel"), "mb-travel", {
|
|
104
160
|
inboxMailboxId: undefined,
|
|
105
|
-
junkMailboxId:
|
|
161
|
+
junkMailboxId: undefined,
|
|
106
162
|
}),
|
|
163
|
+
true,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("classifier: false when the implied destination mailbox hasn't resolved yet", () => {
|
|
168
|
+
assert.equal(
|
|
169
|
+
isAutoMoveInEffect(
|
|
170
|
+
classifierMove(PlacementAction.MoveToInbox, "junk"),
|
|
171
|
+
"mb-inbox",
|
|
172
|
+
{ inboxMailboxId: undefined, junkMailboxId: "mb-junk" },
|
|
173
|
+
),
|
|
107
174
|
false,
|
|
108
175
|
);
|
|
109
176
|
});
|
|
110
177
|
});
|
|
111
178
|
|
|
112
179
|
describe("resolveUndoTargetMailboxId", () => {
|
|
113
|
-
test("resolves the Junk mailbox for fromPlacement='junk'", () => {
|
|
114
|
-
assert.equal(
|
|
180
|
+
test("classifier: resolves the Junk mailbox for fromPlacement='junk'", () => {
|
|
181
|
+
assert.equal(
|
|
182
|
+
resolveUndoTargetMailboxId(
|
|
183
|
+
classifierMove(PlacementAction.MoveToInbox, "junk"),
|
|
184
|
+
ROLE_MAILBOXES,
|
|
185
|
+
),
|
|
186
|
+
"mb-junk",
|
|
187
|
+
);
|
|
115
188
|
});
|
|
116
189
|
|
|
117
|
-
test("resolves the Inbox mailbox for fromPlacement='inbox'", () => {
|
|
190
|
+
test("classifier: resolves the Inbox mailbox for fromPlacement='inbox'", () => {
|
|
118
191
|
assert.equal(
|
|
119
|
-
resolveUndoTargetMailboxId(
|
|
192
|
+
resolveUndoTargetMailboxId(
|
|
193
|
+
classifierMove(PlacementAction.MoveToJunk, "inbox"),
|
|
194
|
+
ROLE_MAILBOXES,
|
|
195
|
+
),
|
|
120
196
|
"mb-inbox",
|
|
121
197
|
);
|
|
122
198
|
});
|
|
123
199
|
|
|
124
|
-
test("undefined
|
|
200
|
+
test("classifier: undefined when the role mailbox hasn't resolved yet", () => {
|
|
125
201
|
assert.equal(
|
|
126
|
-
resolveUndoTargetMailboxId(
|
|
202
|
+
resolveUndoTargetMailboxId(
|
|
203
|
+
classifierMove(PlacementAction.MoveToInbox, "junk"),
|
|
204
|
+
{ inboxMailboxId: "mb-inbox", junkMailboxId: undefined },
|
|
205
|
+
),
|
|
127
206
|
undefined,
|
|
128
207
|
);
|
|
129
208
|
});
|
|
130
209
|
|
|
131
|
-
test("
|
|
210
|
+
test("filter: undo targets the recorded source mailbox verbatim", () => {
|
|
132
211
|
assert.equal(
|
|
133
|
-
resolveUndoTargetMailboxId(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
212
|
+
resolveUndoTargetMailboxId(
|
|
213
|
+
filterMove("mb-work", "mb-travel"),
|
|
214
|
+
ROLE_MAILBOXES,
|
|
215
|
+
),
|
|
216
|
+
"mb-work",
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("undefined when autoMoved is absent", () => {
|
|
221
|
+
assert.equal(
|
|
222
|
+
resolveUndoTargetMailboxId(undefined, ROLE_MAILBOXES),
|
|
137
223
|
undefined,
|
|
138
224
|
);
|
|
139
225
|
});
|
package/src/lib/auto-moved.ts
CHANGED
|
@@ -12,17 +12,35 @@ const FROM_PLACEMENT_LABEL: Record<string, string> = {
|
|
|
12
12
|
junk: "Junk",
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
/** A standing-filter move names an arbitrary destination via `destinationMailboxId`; a classifier move names a direction via `action`. */
|
|
16
|
+
const isFilterMove = (autoMoved: RemitImapAutoMovedInfo): boolean =>
|
|
17
|
+
autoMoved.destinationMailboxId !== undefined;
|
|
18
|
+
|
|
15
19
|
/**
|
|
16
20
|
* Plain-language badge text, e.g. "Moved from Junk by Remit". No jargon
|
|
17
21
|
* (verdict/confidence/dryRun never surface) — mirrors the no-jargon precedent
|
|
18
22
|
* in `rescue-candidates.ts`.
|
|
23
|
+
*
|
|
24
|
+
* A classifier move reads its source from the `fromPlacement` role. A
|
|
25
|
+
* standing-filter move moved from an arbitrary folder, so the caller resolves
|
|
26
|
+
* that folder's display name and passes it as `sourceFolderName`; absent (name
|
|
27
|
+
* not resolved yet) falls back to "another folder".
|
|
19
28
|
*/
|
|
20
|
-
export const autoMovedLabel = (
|
|
21
|
-
|
|
29
|
+
export const autoMovedLabel = (
|
|
30
|
+
autoMoved: RemitImapAutoMovedInfo,
|
|
31
|
+
sourceFolderName?: string,
|
|
32
|
+
): string => {
|
|
33
|
+
if (isFilterMove(autoMoved)) {
|
|
34
|
+
return `Moved from ${sourceFolderName ?? "another folder"} by Remit`;
|
|
35
|
+
}
|
|
36
|
+
const from = autoMoved.fromPlacement ?? "";
|
|
37
|
+
return `Moved from ${FROM_PLACEMENT_LABEL[from] ?? "another folder"} by Remit`;
|
|
38
|
+
};
|
|
22
39
|
|
|
23
40
|
/**
|
|
24
|
-
* The mailbox the verdict's `action` implies as the destination — where
|
|
25
|
-
* message should currently sit for the move to still be "in
|
|
41
|
+
* The mailbox the verdict's `action` implies as the destination — where a
|
|
42
|
+
* classifier-moved message should currently sit for the move to still be "in
|
|
43
|
+
* effect".
|
|
26
44
|
*/
|
|
27
45
|
const impliedDestinationMailboxId = (
|
|
28
46
|
action: string | undefined,
|
|
@@ -34,12 +52,13 @@ const impliedDestinationMailboxId = (
|
|
|
34
52
|
};
|
|
35
53
|
|
|
36
54
|
/**
|
|
37
|
-
* True only while the auto-move is still in effect: the message currently
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
55
|
+
* True only while the auto-move is still in effect: the message currently sits
|
|
56
|
+
* in the mailbox the move targeted. A standing-filter move targets the
|
|
57
|
+
* `destinationMailboxId` it recorded; a classifier move targets the mailbox its
|
|
58
|
+
* `action` implies. Once the message moves elsewhere (an undo, or any other
|
|
59
|
+
* move), this returns false — the badge has no local "dismissed" flag; it
|
|
60
|
+
* re-derives from current placement every render, per the data-flow rule that a
|
|
61
|
+
* projection never forks from its source (doc/rules/data-flow.md).
|
|
43
62
|
*/
|
|
44
63
|
export const isAutoMoveInEffect = (
|
|
45
64
|
autoMoved: RemitImapAutoMovedInfo | undefined,
|
|
@@ -47,22 +66,29 @@ export const isAutoMoveInEffect = (
|
|
|
47
66
|
mailboxes: AutoMovedRoleMailboxes,
|
|
48
67
|
): boolean => {
|
|
49
68
|
if (!autoMoved || !currentMailboxId) return false;
|
|
50
|
-
const destination =
|
|
69
|
+
const destination = isFilterMove(autoMoved)
|
|
70
|
+
? autoMoved.destinationMailboxId
|
|
71
|
+
: impliedDestinationMailboxId(autoMoved.action, mailboxes);
|
|
51
72
|
return destination !== undefined && destination === currentMailboxId;
|
|
52
73
|
};
|
|
53
74
|
|
|
54
75
|
/**
|
|
55
|
-
* Resolve the undo destination: the
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
76
|
+
* Resolve the undo destination: where the message was before the move. A
|
|
77
|
+
* standing-filter move recorded the exact source mailbox (`fromMailboxId`),
|
|
78
|
+
* used verbatim. A classifier move recorded only a role (`fromPlacement`,
|
|
79
|
+
* always `inbox` or `junk` for an actionable verdict — the Tier 0 classifier
|
|
80
|
+
* only fires move-to-inbox from junk and move-to-junk from inbox, see
|
|
81
|
+
* `classifyPlacement.ts`), resolved to the account's mailbox for that role.
|
|
82
|
+
* `undefined` when the account has no mailbox for that role, or the source
|
|
83
|
+
* cannot be resolved.
|
|
60
84
|
*/
|
|
61
85
|
export const resolveUndoTargetMailboxId = (
|
|
62
|
-
|
|
86
|
+
autoMoved: RemitImapAutoMovedInfo | undefined,
|
|
63
87
|
mailboxes: AutoMovedRoleMailboxes,
|
|
64
88
|
): string | undefined => {
|
|
65
|
-
if (
|
|
66
|
-
if (
|
|
89
|
+
if (!autoMoved) return undefined;
|
|
90
|
+
if (isFilterMove(autoMoved)) return autoMoved.fromMailboxId;
|
|
91
|
+
if (autoMoved.fromPlacement === "inbox") return mailboxes.inboxMailboxId;
|
|
92
|
+
if (autoMoved.fromPlacement === "junk") return mailboxes.junkMailboxId;
|
|
67
93
|
return undefined;
|
|
68
94
|
};
|
|
@@ -9,10 +9,13 @@ import {
|
|
|
9
9
|
} from "@remit/ui";
|
|
10
10
|
import type { BugReportContext } from "./bug-report";
|
|
11
11
|
import { buildBugReportDetails, buildGitHubIssueUrl } from "./bug-report";
|
|
12
|
+
import type { RequestBreadcrumb } from "./request-breadcrumbs";
|
|
13
|
+
import type { RouteBreadcrumb } from "./route-breadcrumbs";
|
|
12
14
|
|
|
13
15
|
const baseCtx: BugReportContext = {
|
|
14
|
-
appSha: "abcdef1234567890abcdef1234567890abcdef12",
|
|
15
16
|
appShortSha: "abcdef1",
|
|
17
|
+
appCommitUrl:
|
|
18
|
+
"https://github.com/remit-mail/reader/commit/abcdef1234567890abcdef1234567890abcdef12",
|
|
16
19
|
appBuildTime: "2024-01-15T10:30:00.000Z",
|
|
17
20
|
userAgent: "Mozilla/5.0 (Test Browser)",
|
|
18
21
|
viewport: "1440×900",
|
|
@@ -20,6 +23,8 @@ const baseCtx: BugReportContext = {
|
|
|
20
23
|
timezone: "Europe/Amsterdam",
|
|
21
24
|
href: "https://app.example.com/mail/inbox?q=test",
|
|
22
25
|
recentErrors: [],
|
|
26
|
+
requests: [],
|
|
27
|
+
routes: [],
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
/**
|
|
@@ -145,6 +150,144 @@ describe("buildGitHubIssueUrl", () => {
|
|
|
145
150
|
const url = buildGitHubIssueUrl(ctx);
|
|
146
151
|
assert.doesNotThrow(() => new URL(url), "URL must be valid");
|
|
147
152
|
});
|
|
153
|
+
|
|
154
|
+
it("links the short SHA to the build commit", () => {
|
|
155
|
+
const decoded = decode(buildGitHubIssueUrl(baseCtx));
|
|
156
|
+
assert.ok(
|
|
157
|
+
decoded.includes(
|
|
158
|
+
"[`abcdef1`](https://github.com/remit-mail/reader/commit/abcdef1234567890abcdef1234567890abcdef12)",
|
|
159
|
+
),
|
|
160
|
+
"Expected the version to link the commit",
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("renders the version unlinked when the build has no real SHA", () => {
|
|
165
|
+
const decoded = decode(
|
|
166
|
+
buildGitHubIssueUrl({
|
|
167
|
+
...baseCtx,
|
|
168
|
+
appShortSha: "dev",
|
|
169
|
+
appCommitUrl: undefined,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
assert.ok(
|
|
173
|
+
decoded.includes("**Version**: `dev`"),
|
|
174
|
+
"Expected an unlinked dev version",
|
|
175
|
+
);
|
|
176
|
+
assert.ok(
|
|
177
|
+
!decoded.includes("/commit/dev"),
|
|
178
|
+
"A dev build must not produce a dead commit link",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("buildGitHubIssueUrl — request breadcrumbs", () => {
|
|
184
|
+
const requests: RequestBreadcrumb[] = [
|
|
185
|
+
{
|
|
186
|
+
method: "GET",
|
|
187
|
+
path: "/api/messages/abc-123",
|
|
188
|
+
status: 200,
|
|
189
|
+
durationMs: 42,
|
|
190
|
+
correlationId: "corr-ok",
|
|
191
|
+
timestamp: "2024-06-12T08:00:01.000Z",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
method: "POST",
|
|
195
|
+
path: "/api/messages/abc-123/move",
|
|
196
|
+
status: 500,
|
|
197
|
+
durationMs: 130,
|
|
198
|
+
correlationId: "corr-fail",
|
|
199
|
+
timestamp: "2024-06-12T08:00:02.000Z",
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
it("renders a table of recent API requests", () => {
|
|
204
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, requests }));
|
|
205
|
+
assert.ok(decoded.includes("## Recent API requests"));
|
|
206
|
+
assert.ok(decoded.includes("/api/messages/abc-123/move"));
|
|
207
|
+
assert.ok(
|
|
208
|
+
decoded.includes("corr-fail"),
|
|
209
|
+
"Expected the correlation id in the table",
|
|
210
|
+
);
|
|
211
|
+
assert.ok(decoded.includes("| GET |"));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("shows (none) when no requests were captured", () => {
|
|
215
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, requests: [] }));
|
|
216
|
+
assert.ok(decoded.includes("## Recent API requests"));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("calls out the failing request in its own section", () => {
|
|
220
|
+
const failingRequest = requests[1];
|
|
221
|
+
const decoded = decode(
|
|
222
|
+
buildGitHubIssueUrl({ ...baseCtx, requests, failingRequest }),
|
|
223
|
+
);
|
|
224
|
+
assert.ok(decoded.includes("## Failing request"));
|
|
225
|
+
assert.ok(decoded.includes("**Endpoint**: `/api/messages/abc-123/move`"));
|
|
226
|
+
assert.ok(decoded.includes("**Method**: POST"));
|
|
227
|
+
assert.ok(decoded.includes("**Status**: 500"));
|
|
228
|
+
assert.ok(decoded.includes("**Correlation id**: corr-fail"));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("describes a transport failure as no response", () => {
|
|
232
|
+
const failingRequest: RequestBreadcrumb = {
|
|
233
|
+
method: "GET",
|
|
234
|
+
path: "/api/messages",
|
|
235
|
+
status: 0,
|
|
236
|
+
durationMs: 5000,
|
|
237
|
+
timestamp: "2024-06-12T08:00:03.000Z",
|
|
238
|
+
};
|
|
239
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, failingRequest }));
|
|
240
|
+
assert.ok(decoded.includes("no response (transport failure)"));
|
|
241
|
+
assert.ok(decoded.includes("**Correlation id**: (none)"));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("no breadcrumb ever carries a query string or a body", () => {
|
|
245
|
+
// A breadcrumb path is redacted at capture (request-breadcrumbs.ts), so
|
|
246
|
+
// even if the fetch layer saw a `?q=` search or a request body, the
|
|
247
|
+
// assembled requests table carries neither. Scope the check to that
|
|
248
|
+
// section — the `## URL` section legitimately keeps the full href.
|
|
249
|
+
const requestsWithQueryShaped: RequestBreadcrumb[] = [
|
|
250
|
+
{
|
|
251
|
+
method: "POST",
|
|
252
|
+
path: "/api/search",
|
|
253
|
+
status: 200,
|
|
254
|
+
durationMs: 5,
|
|
255
|
+
correlationId: "c1",
|
|
256
|
+
timestamp: "2024-06-12T08:00:01.000Z",
|
|
257
|
+
},
|
|
258
|
+
];
|
|
259
|
+
const decoded = decode(
|
|
260
|
+
buildGitHubIssueUrl({ ...baseCtx, requests: requestsWithQueryShaped }),
|
|
261
|
+
);
|
|
262
|
+
const section = decoded.slice(decoded.indexOf("## Recent API requests"));
|
|
263
|
+
assert.ok(
|
|
264
|
+
!section.includes("?"),
|
|
265
|
+
"A breadcrumb path must never carry a query string",
|
|
266
|
+
);
|
|
267
|
+
assert.ok(
|
|
268
|
+
!/"password"|"subject"|"body":/.test(section),
|
|
269
|
+
"A breadcrumb must never carry a request/response body",
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("buildGitHubIssueUrl — navigation trail", () => {
|
|
275
|
+
const routes: RouteBreadcrumb[] = [
|
|
276
|
+
{ path: "/mail/inbox", timestamp: "2024-06-12T07:59:58.000Z" },
|
|
277
|
+
{ path: "/mail/message/abc-123", timestamp: "2024-06-12T08:00:00.000Z" },
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
it("renders the navigation trail so Steps to reproduce has context", () => {
|
|
281
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, routes }));
|
|
282
|
+
assert.ok(decoded.includes("## Navigation trail"));
|
|
283
|
+
assert.ok(decoded.includes("/mail/inbox"));
|
|
284
|
+
assert.ok(decoded.includes("/mail/message/abc-123"));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("still emits the Steps to reproduce template", () => {
|
|
288
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, routes }));
|
|
289
|
+
assert.ok(decoded.includes("## Steps to reproduce"));
|
|
290
|
+
});
|
|
148
291
|
});
|
|
149
292
|
|
|
150
293
|
describe("buildGitHubIssueUrl — seeded from a caught error", () => {
|
package/src/lib/bug-report.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { QuarantineReportSections } from "@remit/ui";
|
|
2
2
|
import {
|
|
3
3
|
APP_BUILD_TIME,
|
|
4
|
-
APP_SHA,
|
|
5
4
|
APP_SHORT_SHA,
|
|
5
|
+
GITHUB_COMMIT_URL,
|
|
6
6
|
GITHUB_NEW_ISSUE_URL,
|
|
7
7
|
} from "./app-info";
|
|
8
8
|
import { getRecentErrors } from "./console-errors";
|
|
9
|
+
import {
|
|
10
|
+
getFailingRequest,
|
|
11
|
+
getRecentRequests,
|
|
12
|
+
type RequestBreadcrumb,
|
|
13
|
+
} from "./request-breadcrumbs";
|
|
14
|
+
import { getRecentRoutes, type RouteBreadcrumb } from "./route-breadcrumbs";
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* A GitHub new-issue URL is capped (~8 KB). We budget below that so the stack
|
|
@@ -19,8 +25,9 @@ const TRUNCATION_MARKER =
|
|
|
19
25
|
"\n… (truncated — the copy action on this screen has the full report)";
|
|
20
26
|
|
|
21
27
|
export interface BugReportContext {
|
|
22
|
-
appSha: string;
|
|
23
28
|
appShortSha: string;
|
|
29
|
+
/** Commit URL for the build, or undefined for a local "dev" build (no link). */
|
|
30
|
+
appCommitUrl?: string;
|
|
24
31
|
appBuildTime: string;
|
|
25
32
|
userAgent: string;
|
|
26
33
|
viewport: string;
|
|
@@ -28,6 +35,12 @@ export interface BugReportContext {
|
|
|
28
35
|
timezone: string;
|
|
29
36
|
href: string;
|
|
30
37
|
recentErrors: readonly string[];
|
|
38
|
+
/** Recent API calls, metadata only — method/path/status/duration/correlation. */
|
|
39
|
+
requests: readonly RequestBreadcrumb[];
|
|
40
|
+
/** Recent in-app navigations, oldest first. */
|
|
41
|
+
routes: readonly RouteBreadcrumb[];
|
|
42
|
+
/** The request that most likely triggered the report (transport or >= 400). */
|
|
43
|
+
failingRequest?: RequestBreadcrumb;
|
|
31
44
|
/** The message of the error that triggered the report, when seeded from one. */
|
|
32
45
|
errorMessage?: string;
|
|
33
46
|
/** The error's stacktrace, when available. */
|
|
@@ -56,8 +69,8 @@ export interface BugReportSeed {
|
|
|
56
69
|
|
|
57
70
|
export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
|
|
58
71
|
return {
|
|
59
|
-
appSha: APP_SHA,
|
|
60
72
|
appShortSha: APP_SHORT_SHA,
|
|
73
|
+
appCommitUrl: GITHUB_COMMIT_URL,
|
|
61
74
|
appBuildTime: APP_BUILD_TIME,
|
|
62
75
|
userAgent: navigator.userAgent,
|
|
63
76
|
viewport: `${window.innerWidth}×${window.innerHeight}`,
|
|
@@ -65,6 +78,9 @@ export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
|
|
|
65
78
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
66
79
|
href: window.location.href,
|
|
67
80
|
recentErrors: getRecentErrors(),
|
|
81
|
+
requests: getRecentRequests(),
|
|
82
|
+
routes: getRecentRoutes(),
|
|
83
|
+
failingRequest: getFailingRequest(),
|
|
68
84
|
errorMessage: seed?.errorMessage,
|
|
69
85
|
stack: seed?.stack,
|
|
70
86
|
componentStack: seed?.componentStack,
|
|
@@ -77,6 +93,59 @@ function fencedBlock(content: string): string {
|
|
|
77
93
|
return ["```", content, "```"].join("\n");
|
|
78
94
|
}
|
|
79
95
|
|
|
96
|
+
/** `2024-06-12T08:00:01.234Z` → `08:00:01` — the wall-clock time, compact. */
|
|
97
|
+
function clockTime(iso: string): string {
|
|
98
|
+
const match = /T(\d{2}:\d{2}:\d{2})/.exec(iso);
|
|
99
|
+
return match ? match[1] : iso;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function versionLine(ctx: BugReportContext): string {
|
|
103
|
+
const version = ctx.appCommitUrl
|
|
104
|
+
? `[\`${ctx.appShortSha}\`](${ctx.appCommitUrl})`
|
|
105
|
+
: `\`${ctx.appShortSha}\``;
|
|
106
|
+
return `- **Version**: ${version} built ${ctx.appBuildTime}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function failingRequestSection(request: RequestBreadcrumb): string[] {
|
|
110
|
+
const status =
|
|
111
|
+
request.status === 0
|
|
112
|
+
? "no response (transport failure)"
|
|
113
|
+
: `${request.status}`;
|
|
114
|
+
return [
|
|
115
|
+
"## Failing request",
|
|
116
|
+
`- **Endpoint**: \`${request.path}\``,
|
|
117
|
+
`- **Method**: ${request.method}`,
|
|
118
|
+
`- **Status**: ${status}`,
|
|
119
|
+
`- **Correlation id**: ${request.correlationId ?? "(none)"}`,
|
|
120
|
+
"",
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function requestsSection(requests: readonly RequestBreadcrumb[]): string[] {
|
|
125
|
+
if (requests.length === 0) {
|
|
126
|
+
return ["## Recent API requests", "(none)", ""];
|
|
127
|
+
}
|
|
128
|
+
const rows = requests.map((r) => {
|
|
129
|
+
const status = r.status === 0 ? "ERR" : `${r.status}`;
|
|
130
|
+
return `| ${clockTime(r.timestamp)} | ${r.method} | \`${r.path}\` | ${status} | ${r.durationMs}ms | ${r.correlationId ?? ""} |`;
|
|
131
|
+
});
|
|
132
|
+
return [
|
|
133
|
+
"## Recent API requests",
|
|
134
|
+
"| Time | Method | Path | Status | Duration | Correlation |",
|
|
135
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
136
|
+
...rows,
|
|
137
|
+
"",
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function navigationSection(routes: readonly RouteBreadcrumb[]): string[] {
|
|
142
|
+
if (routes.length === 0) {
|
|
143
|
+
return ["## Navigation trail", "(none)", ""];
|
|
144
|
+
}
|
|
145
|
+
const rows = routes.map((r) => `- ${clockTime(r.timestamp)} \`${r.path}\``);
|
|
146
|
+
return ["## Navigation trail", ...rows, ""];
|
|
147
|
+
}
|
|
148
|
+
|
|
80
149
|
interface BodyOverrides {
|
|
81
150
|
stack?: string;
|
|
82
151
|
/** Replaces the MIME-tree section only; it is fenced after this is applied. */
|
|
@@ -94,7 +163,7 @@ function buildIssueBody(
|
|
|
94
163
|
|
|
95
164
|
const lines: string[] = [
|
|
96
165
|
"## Environment",
|
|
97
|
-
|
|
166
|
+
versionLine(ctx),
|
|
98
167
|
`- **Browser**: ${ctx.userAgent}`,
|
|
99
168
|
`- **Viewport**: ${ctx.viewport}`,
|
|
100
169
|
`- **Time**: ${ctx.timestamp} (${ctx.timezone})`,
|
|
@@ -108,6 +177,10 @@ function buildIssueBody(
|
|
|
108
177
|
lines.push("## Error", ctx.errorMessage, "");
|
|
109
178
|
}
|
|
110
179
|
|
|
180
|
+
if (ctx.failingRequest) {
|
|
181
|
+
lines.push(...failingRequestSection(ctx.failingRequest));
|
|
182
|
+
}
|
|
183
|
+
|
|
111
184
|
if (ctx.quarantine) {
|
|
112
185
|
const structure =
|
|
113
186
|
overrides?.quarantineStructure ?? ctx.quarantine.structure;
|
|
@@ -130,6 +203,9 @@ function buildIssueBody(
|
|
|
130
203
|
lines.push("## Component stack", fencedBlock(ctx.componentStack), "");
|
|
131
204
|
}
|
|
132
205
|
|
|
206
|
+
lines.push(...requestsSection(ctx.requests));
|
|
207
|
+
lines.push(...navigationSection(ctx.routes));
|
|
208
|
+
|
|
133
209
|
lines.push(
|
|
134
210
|
"## Recent console errors",
|
|
135
211
|
errorSection,
|
package/src/lib/network-error.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* boundary makes the question decidable and the browser's wording irrelevant.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { recordRequest } from "./request-breadcrumbs";
|
|
19
|
+
|
|
18
20
|
/** A request that never reached a server. Always soft — never escalated. */
|
|
19
21
|
export class NetworkError extends Error {
|
|
20
22
|
constructor(cause: unknown) {
|
|
@@ -47,12 +49,26 @@ const isDeliberateAbort = (error: unknown): boolean =>
|
|
|
47
49
|
* `fetch`, with transport failures tagged. Every app-owned request goes through
|
|
48
50
|
* this — the generated client is configured with it, and the hand-written
|
|
49
51
|
* wrapper calls it — so a `NetworkError` downstream is a fact, not an inference.
|
|
52
|
+
*
|
|
53
|
+
* It is also the one place every request is seen, so it records a metadata-only
|
|
54
|
+
* breadcrumb (method, path, status, duration, correlation id) for bug reports.
|
|
55
|
+
* A deliberate abort is not a request outcome worth reporting, so it is
|
|
56
|
+
* re-thrown before any breadcrumb is written.
|
|
50
57
|
*/
|
|
51
58
|
export const taggedFetch: typeof fetch = async (input, init) => {
|
|
59
|
+
const startedAt = performance.now();
|
|
52
60
|
try {
|
|
53
|
-
|
|
61
|
+
const response = await fetch(input, init);
|
|
62
|
+
recordRequest({
|
|
63
|
+
input,
|
|
64
|
+
init,
|
|
65
|
+
response,
|
|
66
|
+
durationMs: performance.now() - startedAt,
|
|
67
|
+
});
|
|
68
|
+
return response;
|
|
54
69
|
} catch (error) {
|
|
55
70
|
if (isDeliberateAbort(error)) throw error;
|
|
71
|
+
recordRequest({ input, init, durationMs: performance.now() - startedAt });
|
|
56
72
|
throw new NetworkError(error);
|
|
57
73
|
}
|
|
58
74
|
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { beforeEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
__resetRequestBreadcrumbs,
|
|
5
|
+
getFailingRequest,
|
|
6
|
+
getRecentRequests,
|
|
7
|
+
recordRequest,
|
|
8
|
+
requestPath,
|
|
9
|
+
} from "./request-breadcrumbs";
|
|
10
|
+
|
|
11
|
+
const response = (
|
|
12
|
+
status: number,
|
|
13
|
+
headers: Record<string, string> = {},
|
|
14
|
+
): Response => new Response(null, { status, headers });
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
__resetRequestBreadcrumbs();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("requestPath — redaction boundary", () => {
|
|
21
|
+
it("keeps the pathname and drops the query string", () => {
|
|
22
|
+
assert.equal(requestPath("/api/search?q=secret+query+text"), "/api/search");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("drops the query on an absolute URL too", () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
requestPath("https://app.example.com/api/messages?q=private"),
|
|
28
|
+
"/api/messages",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drops a hash fragment", () => {
|
|
33
|
+
assert.equal(requestPath("/api/thing#section"), "/api/thing");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps opaque path ids (already public in the URL section)", () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
requestPath("/api/messages/abc-123-def"),
|
|
39
|
+
"/api/messages/abc-123-def",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("accepts a URL instance", () => {
|
|
44
|
+
assert.equal(
|
|
45
|
+
requestPath(new URL("https://app.example.com/api/x?q=1")),
|
|
46
|
+
"/api/x",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("accepts a Request instance", () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
requestPath(new Request("https://app.example.com/api/y?token=abc")),
|
|
53
|
+
"/api/y",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("recordRequest", () => {
|
|
59
|
+
it("captures metadata only — never the query or the body", () => {
|
|
60
|
+
recordRequest({
|
|
61
|
+
input: "/api/search?q=confidential",
|
|
62
|
+
init: { method: "POST", body: JSON.stringify({ subject: "secret" }) },
|
|
63
|
+
response: response(200),
|
|
64
|
+
durationMs: 12.6,
|
|
65
|
+
});
|
|
66
|
+
const [entry] = getRecentRequests();
|
|
67
|
+
assert.equal(entry.method, "POST");
|
|
68
|
+
assert.equal(entry.path, "/api/search");
|
|
69
|
+
assert.equal(entry.status, 200);
|
|
70
|
+
assert.equal(entry.durationMs, 13);
|
|
71
|
+
// The breadcrumb shape has no body/query field; assert nothing leaked in.
|
|
72
|
+
const serialized = JSON.stringify(entry);
|
|
73
|
+
assert.ok(
|
|
74
|
+
!serialized.includes("confidential"),
|
|
75
|
+
"query text must not be captured",
|
|
76
|
+
);
|
|
77
|
+
assert.ok(
|
|
78
|
+
!serialized.includes("secret"),
|
|
79
|
+
"request body must not be captured",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("defaults the method to GET", () => {
|
|
84
|
+
recordRequest({
|
|
85
|
+
input: "/api/thing",
|
|
86
|
+
response: response(204),
|
|
87
|
+
durationMs: 1,
|
|
88
|
+
});
|
|
89
|
+
assert.equal(getRecentRequests()[0].method, "GET");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reads the method from a Request instance", () => {
|
|
93
|
+
recordRequest({
|
|
94
|
+
input: new Request("https://app.example.com/api/z", { method: "delete" }),
|
|
95
|
+
response: response(200),
|
|
96
|
+
durationMs: 1,
|
|
97
|
+
});
|
|
98
|
+
assert.equal(getRecentRequests()[0].method, "DELETE");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("records status 0 and no correlation id for a transport failure", () => {
|
|
102
|
+
recordRequest({ input: "/api/thing", durationMs: 5000 });
|
|
103
|
+
const [entry] = getRecentRequests();
|
|
104
|
+
assert.equal(entry.status, 0);
|
|
105
|
+
assert.equal(entry.correlationId, undefined);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reads the correlation id from the first present header", () => {
|
|
109
|
+
recordRequest({
|
|
110
|
+
input: "/api/a",
|
|
111
|
+
response: response(200, { "x-request-id": "req-42" }),
|
|
112
|
+
durationMs: 1,
|
|
113
|
+
});
|
|
114
|
+
assert.equal(getRecentRequests()[0].correlationId, "req-42");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("keeps only the last ten requests", () => {
|
|
118
|
+
for (let i = 0; i < 14; i++) {
|
|
119
|
+
recordRequest({
|
|
120
|
+
input: `/api/n/${i}`,
|
|
121
|
+
response: response(200),
|
|
122
|
+
durationMs: 1,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const entries = getRecentRequests();
|
|
126
|
+
assert.equal(entries.length, 10);
|
|
127
|
+
assert.equal(entries[0].path, "/api/n/4");
|
|
128
|
+
assert.equal(entries[9].path, "/api/n/13");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("getFailingRequest", () => {
|
|
133
|
+
it("returns the most recent HTTP error", () => {
|
|
134
|
+
recordRequest({ input: "/api/ok", response: response(200), durationMs: 1 });
|
|
135
|
+
recordRequest({
|
|
136
|
+
input: "/api/bad",
|
|
137
|
+
response: response(500),
|
|
138
|
+
durationMs: 1,
|
|
139
|
+
});
|
|
140
|
+
recordRequest({
|
|
141
|
+
input: "/api/ok2",
|
|
142
|
+
response: response(200),
|
|
143
|
+
durationMs: 1,
|
|
144
|
+
});
|
|
145
|
+
assert.equal(getFailingRequest()?.path, "/api/bad");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("treats a transport failure as failing", () => {
|
|
149
|
+
recordRequest({ input: "/api/gone", durationMs: 100 });
|
|
150
|
+
assert.equal(getFailingRequest()?.status, 0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns undefined when every request succeeded", () => {
|
|
154
|
+
recordRequest({ input: "/api/ok", response: response(200), durationMs: 1 });
|
|
155
|
+
recordRequest({
|
|
156
|
+
input: "/api/ok2",
|
|
157
|
+
response: response(304),
|
|
158
|
+
durationMs: 1,
|
|
159
|
+
});
|
|
160
|
+
assert.equal(getFailingRequest(), undefined);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A constant-size ring of the last few API calls, captured at the one fetch
|
|
3
|
+
* choke point (`taggedFetch` in network-error.ts). It gives a bug report the
|
|
4
|
+
* request context that a minified stack cannot: which endpoints were hit, in
|
|
5
|
+
* what order, and what they returned — plus the correlation id that ties a
|
|
6
|
+
* failing request to its server-side log line.
|
|
7
|
+
*
|
|
8
|
+
* PRIVACY — the issue tracker is public, so a breadcrumb is METADATA ONLY.
|
|
9
|
+
* Capture records the request method, the URL PATHNAME, the HTTP status, the
|
|
10
|
+
* duration, and a correlation id. It never records a request or response body,
|
|
11
|
+
* and never the query string — a search's `?q=` carries the user's query text,
|
|
12
|
+
* a message fetch's params can carry a subject. `requestPath` strips the query
|
|
13
|
+
* and hash precisely so none of that can reach the report even when the fetch
|
|
14
|
+
* layer has the full URL in hand. Opaque path ids (a message id in the path)
|
|
15
|
+
* are acceptable: they already appear in the report's URL section.
|
|
16
|
+
*
|
|
17
|
+
* Capture is cheap by construction — a fixed-size array of small records, no
|
|
18
|
+
* serialization until a report is assembled.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface RequestBreadcrumb {
|
|
22
|
+
method: string;
|
|
23
|
+
/** URL pathname only — never the query string or hash (see file header). */
|
|
24
|
+
path: string;
|
|
25
|
+
/** HTTP status, or 0 when the request never reached a server (transport failure). */
|
|
26
|
+
status: number;
|
|
27
|
+
durationMs: number;
|
|
28
|
+
/** Server correlation id from a response header, when the response carried one. */
|
|
29
|
+
correlationId?: string;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_ENTRIES = 10;
|
|
34
|
+
|
|
35
|
+
const ring: RequestBreadcrumb[] = [];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Response headers that carry a request/trace correlation id, in preference
|
|
39
|
+
* order. `Headers.get` is case-insensitive, so the lowercase form matches
|
|
40
|
+
* whatever casing the server sent.
|
|
41
|
+
*/
|
|
42
|
+
const CORRELATION_HEADERS = [
|
|
43
|
+
"x-correlation-id",
|
|
44
|
+
"x-request-id",
|
|
45
|
+
"x-amzn-requestid",
|
|
46
|
+
"x-amzn-trace-id",
|
|
47
|
+
"apigw-requestid",
|
|
48
|
+
"x-trace-id",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The pathname of a request, with the query string and hash discarded. This is
|
|
53
|
+
* the redaction boundary: it is the only thing that reads the request URL, and
|
|
54
|
+
* it deliberately keeps nothing but the path so no query text can leak into a
|
|
55
|
+
* breadcrumb.
|
|
56
|
+
*/
|
|
57
|
+
export function requestPath(input: RequestInfo | URL): string {
|
|
58
|
+
const raw =
|
|
59
|
+
typeof input === "string"
|
|
60
|
+
? input
|
|
61
|
+
: input instanceof URL
|
|
62
|
+
? input.href
|
|
63
|
+
: input.url;
|
|
64
|
+
try {
|
|
65
|
+
return new URL(raw, window.location.origin).pathname;
|
|
66
|
+
} catch {
|
|
67
|
+
// A non-URL input (rare) still must not leak a query — cut at the first
|
|
68
|
+
// `?` or `#` and return what precedes it.
|
|
69
|
+
return raw.split(/[?#]/)[0];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requestMethod(input: RequestInfo | URL, init?: RequestInit): string {
|
|
74
|
+
if (init?.method) return init.method.toUpperCase();
|
|
75
|
+
if (typeof input !== "string" && !(input instanceof URL) && input.method) {
|
|
76
|
+
return input.method.toUpperCase();
|
|
77
|
+
}
|
|
78
|
+
return "GET";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function correlationIdFrom(response: Response): string | undefined {
|
|
82
|
+
for (const header of CORRELATION_HEADERS) {
|
|
83
|
+
const value = response.headers.get(header);
|
|
84
|
+
if (value) return value;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function push(entry: RequestBreadcrumb): void {
|
|
90
|
+
ring.push(entry);
|
|
91
|
+
if (ring.length > MAX_ENTRIES) ring.shift();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Record the outcome of one fetch. Called from `taggedFetch` for both a
|
|
96
|
+
* completed response and a transport failure (no response, status 0). Only the
|
|
97
|
+
* metadata listed in `RequestBreadcrumb` is read — never `init.body` or the
|
|
98
|
+
* response body.
|
|
99
|
+
*/
|
|
100
|
+
export function recordRequest(args: {
|
|
101
|
+
input: RequestInfo | URL;
|
|
102
|
+
init?: RequestInit;
|
|
103
|
+
response?: Response;
|
|
104
|
+
durationMs: number;
|
|
105
|
+
}): void {
|
|
106
|
+
const { input, init, response, durationMs } = args;
|
|
107
|
+
push({
|
|
108
|
+
method: requestMethod(input, init),
|
|
109
|
+
path: requestPath(input),
|
|
110
|
+
status: response?.status ?? 0,
|
|
111
|
+
durationMs: Math.round(durationMs),
|
|
112
|
+
correlationId: response ? correlationIdFrom(response) : undefined,
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The captured breadcrumbs, oldest first. A copy — callers cannot mutate the ring. */
|
|
118
|
+
export function getRecentRequests(): readonly RequestBreadcrumb[] {
|
|
119
|
+
return ring.slice();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The most recent request that failed — a transport failure (status 0) or an
|
|
124
|
+
* HTTP error (>= 400). This is the request a report should call out explicitly,
|
|
125
|
+
* so nobody has to decode a minified stack to learn what broke.
|
|
126
|
+
*/
|
|
127
|
+
export function getFailingRequest(): RequestBreadcrumb | undefined {
|
|
128
|
+
for (let i = ring.length - 1; i >= 0; i--) {
|
|
129
|
+
const entry = ring[i];
|
|
130
|
+
if (entry.status === 0 || entry.status >= 400) return entry;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Test-only: clear the ring. */
|
|
136
|
+
export function __resetRequestBreadcrumbs(): void {
|
|
137
|
+
ring.length = 0;
|
|
138
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { beforeEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
__resetRouteBreadcrumbs,
|
|
5
|
+
getRecentRoutes,
|
|
6
|
+
recordRoute,
|
|
7
|
+
} from "./route-breadcrumbs";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
__resetRouteBreadcrumbs();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("recordRoute", () => {
|
|
14
|
+
it("captures the pathname with a timestamp", () => {
|
|
15
|
+
recordRoute("/mail/inbox");
|
|
16
|
+
const [entry] = getRecentRoutes();
|
|
17
|
+
assert.equal(entry.path, "/mail/inbox");
|
|
18
|
+
assert.ok(entry.timestamp);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("strips a query string — never the user's search text", () => {
|
|
22
|
+
recordRoute("/mail/search?q=confidential");
|
|
23
|
+
const [entry] = getRecentRoutes();
|
|
24
|
+
assert.equal(entry.path, "/mail/search");
|
|
25
|
+
assert.ok(!JSON.stringify(entry).includes("confidential"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("collapses a repeated navigation to the same path", () => {
|
|
29
|
+
recordRoute("/mail/inbox");
|
|
30
|
+
recordRoute("/mail/inbox");
|
|
31
|
+
assert.equal(getRecentRoutes().length, 1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps only the last eight navigations", () => {
|
|
35
|
+
for (let i = 0; i < 12; i++) recordRoute(`/mail/${i}`);
|
|
36
|
+
const entries = getRecentRoutes();
|
|
37
|
+
assert.equal(entries.length, 8);
|
|
38
|
+
assert.equal(entries[0].path, "/mail/4");
|
|
39
|
+
assert.equal(entries[7].path, "/mail/11");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A constant-size ring of the last few in-app navigations, captured from the
|
|
3
|
+
* router's `onResolved` event (see router.tsx). It gives a bug report a
|
|
4
|
+
* navigation trail, so "Steps to reproduce" has something concrete even when
|
|
5
|
+
* the reporter leaves the template blank.
|
|
6
|
+
*
|
|
7
|
+
* PRIVACY — like request breadcrumbs, this is METADATA ONLY and the tracker is
|
|
8
|
+
* public. A route entry is the resolved PATHNAME and a timestamp. The query
|
|
9
|
+
* string is dropped: a search route's `?q=` carries the user's query text.
|
|
10
|
+
* Opaque path ids (a message id in the path) are acceptable — they already
|
|
11
|
+
* appear in the report's URL section.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface RouteBreadcrumb {
|
|
15
|
+
/** Resolved pathname only — never the query string (see file header). */
|
|
16
|
+
path: string;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_ENTRIES = 8;
|
|
21
|
+
|
|
22
|
+
const ring: RouteBreadcrumb[] = [];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Record a navigation. `path` is expected to be a pathname; any query or hash
|
|
26
|
+
* is stripped defensively so a caller passing a full location cannot leak query
|
|
27
|
+
* text into a breadcrumb.
|
|
28
|
+
*/
|
|
29
|
+
export function recordRoute(path: string): void {
|
|
30
|
+
const clean = path.split(/[?#]/)[0];
|
|
31
|
+
const last = ring[ring.length - 1];
|
|
32
|
+
if (last && last.path === clean) return;
|
|
33
|
+
ring.push({ path: clean, timestamp: new Date().toISOString() });
|
|
34
|
+
if (ring.length > MAX_ENTRIES) ring.shift();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The captured navigations, oldest first. A copy — callers cannot mutate the ring. */
|
|
38
|
+
export function getRecentRoutes(): readonly RouteBreadcrumb[] {
|
|
39
|
+
return ring.slice();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Test-only: clear the ring. */
|
|
43
|
+
export function __resetRouteBreadcrumbs(): void {
|
|
44
|
+
ring.length = 0;
|
|
45
|
+
}
|
package/src/router.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { QueryClient } from "@tanstack/react-query";
|
|
2
2
|
import { createRouter } from "@tanstack/react-router";
|
|
3
|
+
import { recordRoute } from "./lib/route-breadcrumbs";
|
|
3
4
|
import type { Telemetry } from "./lib/telemetry";
|
|
4
5
|
import { routeTree } from "./routeTree.gen";
|
|
5
6
|
|
|
@@ -19,6 +20,9 @@ export const createAppRouter = (
|
|
|
19
20
|
|
|
20
21
|
router.subscribe("onResolved", (event) => {
|
|
21
22
|
telemetry.recordPageView(event.toLocation.pathname);
|
|
23
|
+
// A metadata-only navigation trail for bug reports; pathname only, never
|
|
24
|
+
// the query string (route-breadcrumbs.ts enforces the redaction).
|
|
25
|
+
recordRoute(event.toLocation.pathname);
|
|
22
26
|
});
|
|
23
27
|
|
|
24
28
|
return router;
|