domotion-svg 0.1.1

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.
Files changed (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. package/src/webfont-unicode-range.test.ts +207 -0
@@ -0,0 +1,461 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ embedRemoteImages,
4
+ elementTreeToSvg,
5
+ getLastCaptureWarnings,
6
+ type CapturedElement,
7
+ type CaptureWarning,
8
+ } from "./dom-to-svg.js";
9
+
10
+ /**
11
+ * DM-512: regression tests for `embedRemoteImages` — verifies that http(s)
12
+ * image URLs in the captured tree are fetched and the resulting `data:` URIs
13
+ * propagate into the rendered SVG, so SVGs load in offline image viewers
14
+ * (Preview / QuickLook) instead of breaking on remote-resource lookups from
15
+ * local files.
16
+ */
17
+
18
+ // Tiny 1x1 transparent PNG. Used as the mock fetch payload across all tests.
19
+ const ONE_PX_PNG = Buffer.from(
20
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
21
+ "base64",
22
+ );
23
+ const ONE_PX_PNG_DATA_URI = `data:image/png;base64,${ONE_PX_PNG.toString("base64")}`;
24
+
25
+ function makeElement(overrides: Partial<CapturedElement> = {}): CapturedElement {
26
+ return {
27
+ tag: "div",
28
+ text: "",
29
+ x: 0, y: 0, width: 100, height: 100,
30
+ children: [],
31
+ ...overrides,
32
+ styles: {
33
+ backgroundColor: "rgba(0,0,0,0)",
34
+ borderColor: "rgb(0,0,0)",
35
+ borderWidth: "0",
36
+ borderRadius: "0",
37
+ borderTopLeftRadius: "0",
38
+ borderTopRightRadius: "0",
39
+ borderBottomRightRadius: "0",
40
+ borderBottomLeftRadius: "0",
41
+ borderTopWidth: "0",
42
+ borderRightWidth: "0",
43
+ borderBottomWidth: "0",
44
+ borderLeftWidth: "0",
45
+ borderTopColor: "rgb(0,0,0)",
46
+ borderRightColor: "rgb(0,0,0)",
47
+ borderBottomColor: "rgb(0,0,0)",
48
+ borderLeftColor: "rgb(0,0,0)",
49
+ borderTopStyle: "none",
50
+ borderRightStyle: "none",
51
+ borderBottomStyle: "none",
52
+ borderLeftStyle: "none",
53
+ color: "rgb(0,0,0)",
54
+ fontSize: "16px",
55
+ fontFamily: "sans-serif",
56
+ fontWeight: "400",
57
+ lineHeight: "20px",
58
+ letterSpacing: "normal",
59
+ textAlign: "left",
60
+ textTransform: "none",
61
+ textDecoration: "none",
62
+ textDecorationLine: "none",
63
+ textDecorationStyle: "solid",
64
+ textDecorationColor: "rgb(0,0,0)",
65
+ whiteSpace: "normal",
66
+ wordSpacing: "0",
67
+ verticalAlign: "baseline",
68
+ direction: "ltr",
69
+ writingMode: "horizontal-tb",
70
+ textOverflow: "clip",
71
+ cursor: "auto",
72
+ caretColor: "auto",
73
+ outlineColor: "rgb(0,0,0)",
74
+ outlineWidth: "0",
75
+ outlineStyle: "none",
76
+ outlineOffset: "0",
77
+ boxShadow: "none",
78
+ opacity: "1",
79
+ transform: "none",
80
+ transformOrigin: "50% 50%",
81
+ visibility: "visible",
82
+ borderCollapse: "separate",
83
+ overflowX: "visible",
84
+ overflowY: "visible",
85
+ scrollbarGutter: "auto",
86
+ scrollWidth: 100,
87
+ scrollHeight: 100,
88
+ clientWidth: 100,
89
+ clientHeight: 100,
90
+ scrollTop: 0,
91
+ scrollLeft: 0,
92
+ objectFit: "fill",
93
+ objectPosition: "50% 50%",
94
+ filter: "none",
95
+ backdropFilter: "none",
96
+ mixBlendMode: "normal",
97
+ clipPath: "none",
98
+ mask: "none",
99
+ maskImage: "none",
100
+ maskMode: "match-source",
101
+ maskSize: "auto",
102
+ maskPosition: "0% 0%",
103
+ maskRepeat: "repeat",
104
+ maskComposite: "add",
105
+ listStyleType: "disc",
106
+ listStyleImage: "none",
107
+ display: "block",
108
+ listStylePosition: "outside",
109
+ backgroundImage: "none",
110
+ backgroundSize: "auto",
111
+ backgroundPosition: "0% 0%",
112
+ backgroundRepeat: "repeat",
113
+ backgroundClip: "border-box",
114
+ backgroundOrigin: "padding-box",
115
+ backgroundAttachment: "scroll",
116
+ paddingTop: "0",
117
+ paddingRight: "0",
118
+ paddingBottom: "0",
119
+ paddingLeft: "0",
120
+ borderImageSource: "none",
121
+ borderImageSlice: "100%",
122
+ borderImageWidth: "1",
123
+ borderImageOutset: "0",
124
+ borderImageRepeat: "stretch",
125
+ zIndex: "auto",
126
+ position: "static",
127
+ float: "none",
128
+ ...(overrides.styles ?? {}),
129
+ } as CapturedElement["styles"],
130
+ };
131
+ }
132
+
133
+ describe("embedRemoteImages — DM-512", () => {
134
+ let originalFetch: typeof globalThis.fetch;
135
+ let fetchMock: ReturnType<typeof vi.fn>;
136
+
137
+ beforeEach(() => {
138
+ originalFetch = globalThis.fetch;
139
+ fetchMock = vi.fn(async () => ({
140
+ ok: true,
141
+ arrayBuffer: async () => ONE_PX_PNG.buffer.slice(
142
+ ONE_PX_PNG.byteOffset, ONE_PX_PNG.byteOffset + ONE_PX_PNG.byteLength,
143
+ ),
144
+ headers: { get: (name: string) => name.toLowerCase() === "content-type" ? "image/png" : null },
145
+ } as unknown as Response));
146
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
147
+ });
148
+ afterEach(() => {
149
+ globalThis.fetch = originalFetch;
150
+ });
151
+
152
+ it("inlines an <img>'s http(s) src as a data: URI in the rendered SVG", async () => {
153
+ const url = "https://example.com/dm-512-img-test.png";
154
+ const tree = [makeElement({
155
+ tag: "img",
156
+ x: 0, y: 0, width: 50, height: 50,
157
+ imageSrc: url,
158
+ })];
159
+ await embedRemoteImages(tree);
160
+ const svg = elementTreeToSvg(tree, 100, 100);
161
+ expect(svg).toContain(ONE_PX_PNG_DATA_URI);
162
+ expect(svg).not.toContain(url);
163
+ });
164
+
165
+ it("inlines a CSS background-image url(http://…) source", async () => {
166
+ const url = "https://example.com/dm-512-bg-test.png";
167
+ const tree = [makeElement({
168
+ x: 0, y: 0, width: 100, height: 100,
169
+ styles: { ...makeElement().styles, backgroundImage: `url("${url}")`, backgroundIntrinsic: [{ w: 50, h: 50 }] } as CapturedElement["styles"],
170
+ })];
171
+ await embedRemoteImages(tree);
172
+ const svg = elementTreeToSvg(tree, 100, 100);
173
+ expect(svg).toContain(ONE_PX_PNG_DATA_URI);
174
+ expect(svg).not.toContain(url);
175
+ });
176
+
177
+ it("dedupes by URL — multiple consumers share one fetch", async () => {
178
+ const url = "https://example.com/dm-512-shared.png";
179
+ const tree = [
180
+ makeElement({ tag: "img", imageSrc: url }),
181
+ makeElement({ tag: "img", imageSrc: url }),
182
+ ];
183
+ await embedRemoteImages(tree);
184
+ expect(fetchMock).toHaveBeenCalledTimes(1);
185
+ // DM-528: fetch now receives an init bag with an AbortSignal alongside the URL.
186
+ expect(fetchMock.mock.calls[0][0]).toBe(url);
187
+ });
188
+
189
+ it("leaves the URL unchanged when fetch fails (non-ok response)", async () => {
190
+ fetchMock.mockResolvedValueOnce({
191
+ ok: false,
192
+ status: 404,
193
+ arrayBuffer: async () => new ArrayBuffer(0),
194
+ headers: { get: () => null },
195
+ } as unknown as Response);
196
+ const url = "https://example.com/dm-512-missing.png";
197
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
198
+ // 404 is a 4xx — the retry path doesn't touch it (DM-529).
199
+ await embedRemoteImages(tree);
200
+ const svg = elementTreeToSvg(tree, 100, 100);
201
+ // URL passes through verbatim — the SVG still references the remote
202
+ // image (broken in offline viewers, but renders the same as before
203
+ // the embedRemoteImages pre-pass for the failed URL).
204
+ expect(svg).toContain(url);
205
+ expect(svg).not.toContain(ONE_PX_PNG_DATA_URI);
206
+ });
207
+
208
+ it("leaves data: and file:// URLs untouched (no fetch attempted)", async () => {
209
+ const tree = [
210
+ makeElement({ tag: "img", imageSrc: "data:image/png;base64,iVBOR" }),
211
+ makeElement({ tag: "img", imageSrc: "file:///tmp/local.png" }),
212
+ ];
213
+ await embedRemoteImages(tree);
214
+ expect(fetchMock).not.toHaveBeenCalled();
215
+ });
216
+
217
+ // DM-527: failed fetches must surface via the capture-warnings pipeline so
218
+ // consumers can identify which images didn't inline. Without this, a
219
+ // produced SVG that looks broken in Preview gives no signal about which
220
+ // CDN URL silently failed.
221
+ describe("DM-527 — surfaces fetch failures as warnings", () => {
222
+ it("emits a remote-image warning with URL + HTTP status on non-ok response", async () => {
223
+ fetchMock.mockResolvedValue({
224
+ ok: false,
225
+ status: 503,
226
+ arrayBuffer: async () => new ArrayBuffer(0),
227
+ headers: { get: () => null },
228
+ } as unknown as Response);
229
+ const url = "https://example.com/dm-527-503.png";
230
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
231
+ const warnings: CaptureWarning[] = [];
232
+ await embedRemoteImages(tree, { warnings, retryBackoffMs: 0 });
233
+ expect(warnings).toHaveLength(1);
234
+ expect(warnings[0].feature).toBe("remote-image");
235
+ expect(warnings[0].detail).toContain(url);
236
+ expect(warnings[0].detail).toContain("503");
237
+ expect(warnings[0].selector).toBe("img");
238
+ });
239
+
240
+ it("emits a remote-image warning when fetch throws (DNS / network error)", async () => {
241
+ fetchMock.mockRejectedValue(Object.assign(new Error("getaddrinfo ENOTFOUND host"), { name: "TypeError" }));
242
+ const url = "https://nope.invalid/dm-527-dns.png";
243
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
244
+ const warnings: CaptureWarning[] = [];
245
+ await embedRemoteImages(tree, { warnings, retryBackoffMs: 0 });
246
+ expect(warnings).toHaveLength(1);
247
+ expect(warnings[0].detail).toContain(url);
248
+ expect(warnings[0].detail).toContain("TypeError");
249
+ expect(warnings[0].detail).toContain("ENOTFOUND");
250
+ });
251
+
252
+ it("includes a selector path identifying the originating element", async () => {
253
+ fetchMock.mockResolvedValueOnce({
254
+ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0),
255
+ headers: { get: () => null },
256
+ } as unknown as Response);
257
+ const url = "https://example.com/dm-527-selector.png";
258
+ const tree = [
259
+ makeElement({
260
+ tag: "body",
261
+ children: [
262
+ makeElement({
263
+ tag: "section",
264
+ children: [makeElement({ tag: "img", imageSrc: url })],
265
+ }),
266
+ ],
267
+ }),
268
+ ];
269
+ const warnings: CaptureWarning[] = [];
270
+ await embedRemoteImages(tree, { warnings });
271
+ expect(warnings[0].selector).toBe("body > section > img");
272
+ });
273
+
274
+ it("falls back to getLastCaptureWarnings() when no array is provided", async () => {
275
+ // Reset the module global by running embedRemoteImages once with an
276
+ // empty tree (no-op) and snapshotting the array — we then mutate the
277
+ // same array, so all earlier warnings remain in front.
278
+ const before = getLastCaptureWarnings().length;
279
+ fetchMock.mockResolvedValue({
280
+ ok: false, status: 500, arrayBuffer: async () => new ArrayBuffer(0),
281
+ headers: { get: () => null },
282
+ } as unknown as Response);
283
+ const url = "https://example.com/dm-527-global.png";
284
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
285
+ await embedRemoteImages(tree, { retryBackoffMs: 0 });
286
+ const after = getLastCaptureWarnings();
287
+ expect(after.length).toBe(before + 1);
288
+ expect(after[after.length - 1].detail).toContain(url);
289
+ });
290
+
291
+ it("does NOT emit a warning when the fetch succeeds", async () => {
292
+ const url = "https://example.com/dm-527-ok.png";
293
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
294
+ const warnings: CaptureWarning[] = [];
295
+ await embedRemoteImages(tree, { warnings });
296
+ expect(warnings).toHaveLength(0);
297
+ });
298
+ });
299
+
300
+ // DM-528: a stalled CDN host can't hang the capture indefinitely. Each
301
+ // fetch is bounded by a per-URL AbortController; timed-out fetches surface
302
+ // as `RemoteImageTimeoutError` warnings.
303
+ describe("DM-528 — per-fetch timeout", () => {
304
+ it("aborts a stalled fetch after timeoutMs and emits a timeout warning", async () => {
305
+ // Mock fetch to never resolve unless aborted via the passed signal.
306
+ // Use mockImplementation (not …Once) so the DM-529 retry attempt also
307
+ // sees a stalled fetch — we want both attempts to time out so the
308
+ // warning surfaces.
309
+ fetchMock.mockImplementation((_url: string, init?: { signal?: AbortSignal }) => {
310
+ return new Promise<Response>((_resolve, reject) => {
311
+ const signal = init?.signal;
312
+ if (signal == null) return; // never resolves
313
+ if (signal.aborted) {
314
+ const e = new Error("aborted") as Error & { name: string };
315
+ e.name = "AbortError";
316
+ reject(e);
317
+ return;
318
+ }
319
+ signal.addEventListener("abort", () => {
320
+ const e = new Error("aborted") as Error & { name: string };
321
+ e.name = "AbortError";
322
+ reject(e);
323
+ });
324
+ });
325
+ });
326
+ const url = "https://example.com/dm-528-stalled.png";
327
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
328
+ const warnings: CaptureWarning[] = [];
329
+ const t0 = Date.now();
330
+ await embedRemoteImages(tree, { warnings, timeoutMs: 50, retryBackoffMs: 0 });
331
+ const elapsed = Date.now() - t0;
332
+ expect(elapsed).toBeLessThan(2000); // we should NOT have hung
333
+ expect(warnings).toHaveLength(1);
334
+ expect(warnings[0].feature).toBe("remote-image");
335
+ expect(warnings[0].detail).toContain(url);
336
+ expect(warnings[0].detail).toContain("RemoteImageTimeoutError");
337
+ expect(warnings[0].detail).toContain("50ms");
338
+ // URL stays as-is — the SVG still references the remote image.
339
+ const svg = elementTreeToSvg(tree, 100, 100);
340
+ expect(svg).toContain(url);
341
+ });
342
+
343
+ it("passes an AbortSignal to fetch", async () => {
344
+ const url = "https://example.com/dm-528-signal-check.png";
345
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
346
+ await embedRemoteImages(tree);
347
+ expect(fetchMock).toHaveBeenCalledTimes(1);
348
+ const call = fetchMock.mock.calls[0];
349
+ const init = call[1] as { signal?: AbortSignal } | undefined;
350
+ expect(init?.signal).toBeDefined();
351
+ expect(init!.signal).toBeInstanceOf(AbortSignal);
352
+ });
353
+ });
354
+
355
+ // DM-529: retry once on transient failures (5xx, network error, timeout).
356
+ // Success on retry replaces the URL with the inlined data URI; failure on
357
+ // retry behaves the same as today (URL stays as-is, warning surfaces).
358
+ describe("DM-529 — retry transient failures", () => {
359
+ it("retries on a 5xx response and inlines on the second attempt's success", async () => {
360
+ const okResponse = {
361
+ ok: true,
362
+ status: 200,
363
+ arrayBuffer: async () => ONE_PX_PNG.buffer.slice(
364
+ ONE_PX_PNG.byteOffset, ONE_PX_PNG.byteOffset + ONE_PX_PNG.byteLength,
365
+ ),
366
+ headers: { get: (n: string) => n.toLowerCase() === "content-type" ? "image/png" : null },
367
+ } as unknown as Response;
368
+ fetchMock.mockResolvedValueOnce({
369
+ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0),
370
+ headers: { get: () => null },
371
+ } as unknown as Response);
372
+ fetchMock.mockResolvedValueOnce(okResponse);
373
+ const url = "https://example.com/dm-529-503-then-200.png";
374
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
375
+ const warnings: CaptureWarning[] = [];
376
+ await embedRemoteImages(tree, { warnings, retryBackoffMs: 0 });
377
+ expect(fetchMock).toHaveBeenCalledTimes(2);
378
+ const svg = elementTreeToSvg(tree, 100, 100);
379
+ expect(svg).toContain(ONE_PX_PNG_DATA_URI);
380
+ expect(svg).not.toContain(url);
381
+ expect(warnings).toHaveLength(0); // success on retry → no warning
382
+ });
383
+
384
+ it("retries on a thrown network error and inlines on success", async () => {
385
+ const okResponse = {
386
+ ok: true,
387
+ status: 200,
388
+ arrayBuffer: async () => ONE_PX_PNG.buffer.slice(
389
+ ONE_PX_PNG.byteOffset, ONE_PX_PNG.byteOffset + ONE_PX_PNG.byteLength,
390
+ ),
391
+ headers: { get: (n: string) => n.toLowerCase() === "content-type" ? "image/png" : null },
392
+ } as unknown as Response;
393
+ fetchMock.mockRejectedValueOnce(new TypeError("ECONNRESET"));
394
+ fetchMock.mockResolvedValueOnce(okResponse);
395
+ const url = "https://example.com/dm-529-reset-then-200.png";
396
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
397
+ const warnings: CaptureWarning[] = [];
398
+ await embedRemoteImages(tree, { warnings, retryBackoffMs: 0 });
399
+ expect(fetchMock).toHaveBeenCalledTimes(2);
400
+ expect(warnings).toHaveLength(0);
401
+ });
402
+
403
+ it("emits a warning describing the FINAL failure when the retry also fails", async () => {
404
+ fetchMock.mockResolvedValueOnce({
405
+ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0),
406
+ headers: { get: () => null },
407
+ } as unknown as Response);
408
+ fetchMock.mockResolvedValueOnce({
409
+ ok: false, status: 502, arrayBuffer: async () => new ArrayBuffer(0),
410
+ headers: { get: () => null },
411
+ } as unknown as Response);
412
+ const url = "https://example.com/dm-529-double-fail.png";
413
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
414
+ const warnings: CaptureWarning[] = [];
415
+ await embedRemoteImages(tree, { warnings, retryBackoffMs: 0 });
416
+ expect(fetchMock).toHaveBeenCalledTimes(2);
417
+ expect(warnings).toHaveLength(1);
418
+ // The most recent failure (502) is what we report.
419
+ expect(warnings[0].detail).toContain("502");
420
+ });
421
+
422
+ it("does NOT retry on 4xx (deterministic client errors)", async () => {
423
+ fetchMock.mockResolvedValueOnce({
424
+ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0),
425
+ headers: { get: () => null },
426
+ } as unknown as Response);
427
+ const url = "https://example.com/dm-529-404.png";
428
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
429
+ const warnings: CaptureWarning[] = [];
430
+ await embedRemoteImages(tree, { warnings, retryBackoffMs: 0 });
431
+ expect(fetchMock).toHaveBeenCalledTimes(1); // no retry
432
+ expect(warnings[0].detail).toContain("404");
433
+ });
434
+
435
+ it("respects retries: 0 (no retry attempts)", async () => {
436
+ fetchMock.mockResolvedValueOnce({
437
+ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0),
438
+ headers: { get: () => null },
439
+ } as unknown as Response);
440
+ const url = "https://example.com/dm-529-no-retry.png";
441
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
442
+ const warnings: CaptureWarning[] = [];
443
+ await embedRemoteImages(tree, { warnings, retries: 0 });
444
+ expect(fetchMock).toHaveBeenCalledTimes(1);
445
+ expect(warnings).toHaveLength(1);
446
+ });
447
+
448
+ it("respects retries > 1 (e.g. retries: 2 → up to 3 attempts)", async () => {
449
+ fetchMock.mockResolvedValue({
450
+ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0),
451
+ headers: { get: () => null },
452
+ } as unknown as Response);
453
+ const url = "https://example.com/dm-529-three-attempts.png";
454
+ const tree = [makeElement({ tag: "img", imageSrc: url })];
455
+ const warnings: CaptureWarning[] = [];
456
+ await embedRemoteImages(tree, { warnings, retries: 2, retryBackoffMs: 0 });
457
+ expect(fetchMock).toHaveBeenCalledTimes(3);
458
+ expect(warnings).toHaveLength(1);
459
+ });
460
+ });
461
+ });