@remit/web-client 0.0.24 → 0.0.25

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.24",
3
+ "version": "0.0.25",
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": {
@@ -1,5 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import {
4
+ QUARANTINE_REPORT_DISCLAIMER,
5
+ type QuarantineEntry,
6
+ quarantineDemoEntries,
7
+ quarantineIssueTitle,
8
+ quarantineReportSections,
9
+ } from "@remit/ui";
3
10
  import type { BugReportContext } from "./bug-report";
4
11
  import { buildBugReportDetails, buildGitHubIssueUrl } from "./bug-report";
5
12
 
@@ -211,3 +218,88 @@ describe("buildGitHubIssueUrl — truncation to fit the URL budget", () => {
211
218
  );
212
219
  });
213
220
  });
221
+
222
+ describe("quarantine reports", () => {
223
+ // A flat multipart/mixed of 200 parts — the shape that first broke the
224
+ // fence. A message with a pathological part count is precisely the message
225
+ // that fails to parse, so a long report is the common case, not the tail.
226
+ const entry: QuarantineEntry = {
227
+ ...quarantineDemoEntries[0],
228
+ structure: {
229
+ contentType: "multipart/mixed",
230
+ parts: Array.from({ length: 200 }, () => ({
231
+ contentType: "application/octet-stream",
232
+ })),
233
+ },
234
+ };
235
+
236
+ const ctx = (over?: Partial<BugReportContext>): BugReportContext => ({
237
+ ...baseCtx,
238
+ title: quarantineIssueTitle(entry),
239
+ quarantine: quarantineReportSections(entry),
240
+ ...over,
241
+ });
242
+
243
+ const fenceCount = (body: string): number =>
244
+ (body.match(/^```/gm) ?? []).length;
245
+
246
+ it("carries the diagnostics section and the supplied title", () => {
247
+ const decoded = decode(
248
+ buildGitHubIssueUrl(
249
+ ctx({ quarantine: quarantineReportSections(quarantineDemoEntries[0]) }),
250
+ ),
251
+ );
252
+ assert.ok(decoded.includes("UnterminatedMultipartBoundary"));
253
+ assert.ok(decoded.includes("Message quarantined:"));
254
+ });
255
+
256
+ it("truncates a long report without cutting inside the code fence", () => {
257
+ const url = buildGitHubIssueUrl(ctx());
258
+ const body = decode(url);
259
+ assert.ok(
260
+ url.length <= 8000,
261
+ `Expected a URL under 8000 chars, got ${url.length}`,
262
+ );
263
+ assert.ok(body.includes("truncated"), "Expected a truncation marker");
264
+ assert.equal(
265
+ fenceCount(body) % 2,
266
+ 0,
267
+ `Unbalanced code fence in truncated body:\n${body.slice(-400)}`,
268
+ );
269
+ });
270
+
271
+ it("keeps the redaction disclaimer on a truncated report", () => {
272
+ const body = decode(buildGitHubIssueUrl(ctx()));
273
+ assert.ok(
274
+ body.includes(QUARANTINE_REPORT_DISCLAIMER),
275
+ "A truncated report must still state what was withheld",
276
+ );
277
+ });
278
+
279
+ it("keeps the reproduction template outside the code block", () => {
280
+ const body = decode(buildGitHubIssueUrl(ctx()));
281
+ const afterLastFence = body.slice(body.lastIndexOf("```") + 3);
282
+ assert.ok(afterLastFence.includes("## Steps to reproduce"));
283
+ });
284
+
285
+ it("truncates the longer section when a stack is present too", () => {
286
+ const url = buildGitHubIssueUrl(
287
+ ctx({ stack: "at foo\n".repeat(2000), errorMessage: "boom" }),
288
+ );
289
+ assert.ok(
290
+ url.length <= 8000,
291
+ `Expected a URL under 8000 chars, got ${url.length}`,
292
+ );
293
+ });
294
+
295
+ it("Copy full details keeps every part the URL had to drop", () => {
296
+ const parts = (text: string): number =>
297
+ (text.match(/- application\/octet-stream/g) ?? []).length;
298
+ const details = buildBugReportDetails(ctx());
299
+ assert.equal(parts(details), 200);
300
+ assert.ok(
301
+ parts(decode(buildGitHubIssueUrl(ctx()))) < 200,
302
+ "Expected the URL body to have dropped parts",
303
+ );
304
+ });
305
+ });
@@ -1,3 +1,4 @@
1
+ import type { QuarantineReportSections } from "@remit/ui";
1
2
  import {
2
3
  APP_BUILD_TIME,
3
4
  APP_SHA,
@@ -15,7 +16,7 @@ import { getRecentErrors } from "./console-errors";
15
16
  const MAX_ISSUE_URL_LENGTH = 7500;
16
17
 
17
18
  const TRUNCATION_MARKER =
18
- "\n… (truncated — use “Copy full details” for the full stacktrace)";
19
+ "\n… (truncated — the copy action on this screen has the full report)";
19
20
 
20
21
  export interface BugReportContext {
21
22
  appSha: string;
@@ -33,6 +34,14 @@ export interface BugReportContext {
33
34
  stack?: string;
34
35
  /** React component stack from the error boundary, when available. */
35
36
  componentStack?: string;
37
+ /**
38
+ * A quarantined message's diagnostics, when the report is filed from the
39
+ * quarantine surface. Split into sections by `quarantineReportSections` in
40
+ * @remit/ui so the MIME tree is fenced after truncation, never before.
41
+ */
42
+ quarantine?: QuarantineReportSections;
43
+ /** Overrides the derived title, e.g. for a quarantine report. */
44
+ title?: string;
36
45
  }
37
46
 
38
47
  /** Fields callers can seed from a caught error. */
@@ -40,6 +49,9 @@ export interface BugReportSeed {
40
49
  errorMessage?: string;
41
50
  stack?: string;
42
51
  componentStack?: string;
52
+ quarantine?: QuarantineReportSections;
53
+ /** Overrides the derived title, e.g. for a quarantine report. */
54
+ title?: string;
43
55
  }
44
56
 
45
57
  export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
@@ -56,6 +68,8 @@ export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
56
68
  errorMessage: seed?.errorMessage,
57
69
  stack: seed?.stack,
58
70
  componentStack: seed?.componentStack,
71
+ quarantine: seed?.quarantine,
72
+ title: seed?.title,
59
73
  };
60
74
  }
61
75
 
@@ -63,7 +77,16 @@ function fencedBlock(content: string): string {
63
77
  return ["```", content, "```"].join("\n");
64
78
  }
65
79
 
66
- function buildIssueBody(ctx: BugReportContext, stack?: string): string {
80
+ interface BodyOverrides {
81
+ stack?: string;
82
+ /** Replaces the MIME-tree section only; it is fenced after this is applied. */
83
+ quarantineStructure?: string;
84
+ }
85
+
86
+ function buildIssueBody(
87
+ ctx: BugReportContext,
88
+ overrides?: BodyOverrides,
89
+ ): string {
67
90
  const errorSection =
68
91
  ctx.recentErrors.length > 0
69
92
  ? ctx.recentErrors.map((e) => ` - ${e}`).join("\n")
@@ -85,7 +108,20 @@ function buildIssueBody(ctx: BugReportContext, stack?: string): string {
85
108
  lines.push("## Error", ctx.errorMessage, "");
86
109
  }
87
110
 
88
- const resolvedStack = stack ?? ctx.stack;
111
+ if (ctx.quarantine) {
112
+ const structure =
113
+ overrides?.quarantineStructure ?? ctx.quarantine.structure;
114
+ lines.push(
115
+ ctx.quarantine.head,
116
+ "",
117
+ fencedBlock(structure),
118
+ "",
119
+ ctx.quarantine.disclaimer,
120
+ "",
121
+ );
122
+ }
123
+
124
+ const resolvedStack = overrides?.stack ?? ctx.stack;
89
125
  if (resolvedStack) {
90
126
  lines.push("## Stacktrace", fencedBlock(resolvedStack), "");
91
127
  }
@@ -111,6 +147,7 @@ function buildIssueBody(ctx: BugReportContext, stack?: string): string {
111
147
  }
112
148
 
113
149
  function buildIssueTitle(ctx: BugReportContext): string {
150
+ if (ctx.title) return ctx.title;
114
151
  if (!ctx.errorMessage) return "Bug: ";
115
152
  const firstLine = ctx.errorMessage.split("\n")[0].trim();
116
153
  const clipped =
@@ -118,10 +155,10 @@ function buildIssueTitle(ctx: BugReportContext): string {
118
155
  return `Bug: ${clipped}`;
119
156
  }
120
157
 
121
- function issueUrl(ctx: BugReportContext, stack?: string): string {
158
+ function issueUrl(ctx: BugReportContext, overrides?: BodyOverrides): string {
122
159
  const params = new URLSearchParams({
123
160
  title: buildIssueTitle(ctx),
124
- body: buildIssueBody(ctx, stack),
161
+ body: buildIssueBody(ctx, overrides),
125
162
  });
126
163
  return `${GITHUB_NEW_ISSUE_URL}?${params.toString()}`;
127
164
  }
@@ -137,40 +174,63 @@ export function buildBugReportDetails(ctx: BugReportContext): string {
137
174
 
138
175
  /**
139
176
  * Build a prefilled GitHub new-issue URL. When the full body would exceed the
140
- * URL budget, the stacktrace is truncated (binary-searched to the longest
141
- * prefix that fits) and marked; everything else is preserved. The component
142
- * stack is dropped from the URL when truncation kicks in — it survives in the
143
- * "Copy full details" report.
177
+ * URL budget, the longest truncatable section a stacktrace, or a quarantine
178
+ * report's MIME tree is binary-searched to the longest prefix that fits and
179
+ * marked; everything else is preserved. The component stack is dropped from
180
+ * the URL when truncation kicks in — it survives in the copy-details report.
181
+ *
182
+ * The quarantine MIME tree is handed over unfenced and fenced by
183
+ * `buildIssueBody` afterwards, so a cut can never land inside a code fence.
184
+ * The disclaimer is appended by the builder rather than carried in the
185
+ * truncated text, so a truncated report still states what was withheld.
144
186
  */
145
187
  export function buildGitHubIssueUrl(ctx: BugReportContext): string {
146
188
  const full = issueUrl(ctx);
147
189
  if (full.length <= MAX_ISSUE_URL_LENGTH) return full;
148
190
 
149
- const stack = ctx.stack ?? "";
150
191
  const withoutComponentStack: BugReportContext = {
151
192
  ...ctx,
152
193
  componentStack: undefined,
153
194
  };
154
195
 
155
- let lo = 0;
156
- let hi = stack.length;
157
- let best = 0;
158
- while (lo <= hi) {
159
- const mid = (lo + hi) >> 1;
160
- const candidate = issueUrl(
161
- withoutComponentStack,
162
- stack.slice(0, mid) + TRUNCATION_MARKER,
163
- );
164
- if (candidate.length <= MAX_ISSUE_URL_LENGTH) {
165
- best = mid;
166
- lo = mid + 1;
167
- } else {
168
- hi = mid - 1;
196
+ // Both sections can be present — a seed is not discriminated — and cutting
197
+ // one to nothing does not necessarily fit the other. Truncate the longest
198
+ // first and keep going while the budget is still exceeded.
199
+ const sections: Array<[keyof BodyOverrides, string]> = [];
200
+ if (ctx.stack) sections.push(["stack", ctx.stack]);
201
+ if (ctx.quarantine?.structure) {
202
+ sections.push(["quarantineStructure", ctx.quarantine.structure]);
203
+ }
204
+ sections.sort((a, b) => b[1].length - a[1].length);
205
+
206
+ let overrides: BodyOverrides = {};
207
+ for (const [field, text] of sections) {
208
+ if (
209
+ issueUrl(withoutComponentStack, overrides).length <= MAX_ISSUE_URL_LENGTH
210
+ ) {
211
+ break;
169
212
  }
213
+ let lo = 0;
214
+ let hi = text.length;
215
+ let best = 0;
216
+ while (lo <= hi) {
217
+ const mid = (lo + hi) >> 1;
218
+ const candidate = issueUrl(withoutComponentStack, {
219
+ ...overrides,
220
+ [field]: text.slice(0, mid) + TRUNCATION_MARKER,
221
+ });
222
+ if (candidate.length <= MAX_ISSUE_URL_LENGTH) {
223
+ best = mid;
224
+ lo = mid + 1;
225
+ } else {
226
+ hi = mid - 1;
227
+ }
228
+ }
229
+ overrides = {
230
+ ...overrides,
231
+ [field]: text.slice(0, best) + TRUNCATION_MARKER,
232
+ };
170
233
  }
171
234
 
172
- return issueUrl(
173
- withoutComponentStack,
174
- stack.slice(0, best) + TRUNCATION_MARKER,
175
- );
235
+ return issueUrl(withoutComponentStack, overrides);
176
236
  }