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.
- package/FEATURES.md +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- 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
|
+
});
|