tileserver-gl-light 5.5.0-pre.0 → 5.5.0-pre.2

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 (33) hide show
  1. package/CHANGELOG.md +5 -2
  2. package/docs/usage.rst +9 -1
  3. package/package.json +9 -9
  4. package/public/resources/maplibre-gl-inspect.js +2823 -2770
  5. package/public/resources/maplibre-gl-inspect.js.map +1 -1
  6. package/public/resources/maplibre-gl.css +1 -1
  7. package/public/resources/maplibre-gl.js +4 -4
  8. package/public/resources/maplibre-gl.js.map +1 -1
  9. package/src/main.js +16 -17
  10. package/src/pmtiles_adapter.js +3 -3
  11. package/src/promises.js +1 -1
  12. package/src/render.js +270 -93
  13. package/src/serve_data.js +6 -8
  14. package/src/serve_light.js +0 -1
  15. package/src/serve_rendered.js +372 -205
  16. package/src/server.js +22 -27
  17. package/src/utils.js +17 -18
  18. package/test/fixtures/visual/encoded-path-auto.png +0 -0
  19. package/test/fixtures/visual/linecap-linejoin-bevel-square.png +0 -0
  20. package/test/fixtures/visual/linecap-linejoin-round-round.png +0 -0
  21. package/test/fixtures/visual/path-auto.png +0 -0
  22. package/test/fixtures/visual/static-bbox.png +0 -0
  23. package/test/fixtures/visual/static-bearing-pitch.png +0 -0
  24. package/test/fixtures/visual/static-bearing.png +0 -0
  25. package/test/fixtures/visual/static-border-global.png +0 -0
  26. package/test/fixtures/visual/static-lat-lng.png +0 -0
  27. package/test/fixtures/visual/static-markers.png +0 -0
  28. package/test/fixtures/visual/static-multiple-paths.png +0 -0
  29. package/test/fixtures/visual/static-path-border-isolated.png +0 -0
  30. package/test/fixtures/visual/static-path-border-stroke.png +0 -0
  31. package/test/fixtures/visual/static-path-latlng.png +0 -0
  32. package/test/fixtures/visual/static-pixel-ratio-2x.png +0 -0
  33. package/test/static_images.js +241 -0
package/src/server.js CHANGED
@@ -31,9 +31,9 @@ const packageJson = JSON.parse(
31
31
  );
32
32
  const isLight = packageJson.name.slice(-6) === '-light';
33
33
 
34
- const serve_rendered = (
35
- await import(`${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`)
36
- ).serve_rendered;
34
+ const { serve_rendered } = await import(
35
+ `${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`
36
+ );
37
37
 
38
38
  /**
39
39
  * Starts the server.
@@ -60,8 +60,7 @@ async function start(opts) {
60
60
  app.use(
61
61
  morgan(logFormat, {
62
62
  stream: opts.logFile
63
- ? // eslint-disable-next-line security/detect-non-literal-fs-filename -- logFile is from CLI/config, admin-controlled
64
- fs.createWriteStream(opts.logFile, { flags: 'a' })
63
+ ? fs.createWriteStream(opts.logFile, { flags: 'a' })
65
64
  : process.stdout,
66
65
  skip: (req, res) =>
67
66
  opts.silent && (res.statusCode === 200 || res.statusCode === 304),
@@ -74,9 +73,8 @@ async function start(opts) {
74
73
  if (opts.configPath) {
75
74
  configPath = path.resolve(opts.configPath);
76
75
  try {
77
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- configPath is from CLI argument, expected behavior
78
76
  config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
79
- } catch (e) {
77
+ } catch {
80
78
  console.log('ERROR: Config file not found or invalid!');
81
79
  console.log(' See README.md for instructions and sample data.');
82
80
  process.exit(1);
@@ -109,8 +107,7 @@ async function start(opts) {
109
107
  const startupPromises = [];
110
108
 
111
109
  for (const type of Object.keys(paths)) {
112
- // eslint-disable-next-line security/detect-object-injection -- type is from Object.keys of paths config
113
- // eslint-disable-next-line security/detect-non-literal-fs-filename, security/detect-object-injection -- paths[type] constructed from validated config paths
110
+ // eslint-disable-next-line security/detect-object-injection -- paths[type] constructed from validated config paths
114
111
  if (!fs.existsSync(paths[type])) {
115
112
  console.error(
116
113
  // eslint-disable-next-line security/detect-object-injection -- type is from Object.keys of paths config
@@ -128,7 +125,7 @@ async function start(opts) {
128
125
  */
129
126
  async function getFiles(directory) {
130
127
  // Fetch all entries of the directory and attach type information
131
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- directory is constructed from validated config paths
128
+
132
129
  const dirEntries = await fs.promises.readdir(directory, {
133
130
  withFileTypes: true,
134
131
  });
@@ -157,12 +154,13 @@ async function start(opts) {
157
154
 
158
155
  if (options.dataDecorator) {
159
156
  try {
160
- // eslint-disable-next-line security/detect-non-literal-require -- dataDecorator path is from config file, admin-controlled
161
- options.dataDecoratorFunc = require(
162
- path.resolve(paths.root, options.dataDecorator),
163
- );
157
+ const dataDecoratorPath = path.resolve(paths.root, options.dataDecorator);
158
+
159
+ const module = await import(dataDecoratorPath);
160
+ options.dataDecoratorFunc = module.default;
164
161
  } catch (e) {
165
- // Silently fail if dataDecorator cannot be loaded
162
+ console.error(`Error loading data decorator: ${e}`);
163
+ // Intentionally don't set options.dataDecoratorFunc - let it remain undefined
166
164
  }
167
165
  }
168
166
 
@@ -204,11 +202,11 @@ async function start(opts) {
204
202
  styleJSON = await res.json();
205
203
  } else {
206
204
  const styleFile = path.resolve(options.paths.styles, item.style);
207
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- styleFile constructed from config base path and style name
205
+
208
206
  const styleFileData = await fs.promises.readFile(styleFile);
209
207
  styleJSON = JSON.parse(styleFileData);
210
208
  }
211
- } catch (e) {
209
+ } catch {
212
210
  console.log(`Error getting style file "${item.style}"`);
213
211
  return false;
214
212
  }
@@ -224,7 +222,6 @@ async function start(opts) {
224
222
  (styleSourceId, protocol) => {
225
223
  let dataItemId;
226
224
  for (const id of Object.keys(data)) {
227
- // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
228
225
  if (id === styleSourceId) {
229
226
  // Style id was found in data ids, return that id
230
227
  dataItemId = id;
@@ -301,10 +298,10 @@ async function start(opts) {
301
298
  let currentInputFileValue;
302
299
 
303
300
  // Check for recognized file type keys
304
- if (sourceData.hasOwnProperty('pmtiles')) {
301
+ if (Object.hasOwn(sourceData, 'pmtiles')) {
305
302
  currentFileType = 'pmtiles';
306
303
  currentInputFileValue = sourceData.pmtiles;
307
- } else if (sourceData.hasOwnProperty('mbtiles')) {
304
+ } else if (Object.hasOwn(sourceData, 'mbtiles')) {
308
305
  currentFileType = 'mbtiles';
309
306
  currentInputFileValue = sourceData.mbtiles;
310
307
  }
@@ -319,24 +316,24 @@ async function start(opts) {
319
316
  resolvedInputFile = currentInputFileValue;
320
317
 
321
318
  // Get sparse if present
322
- if (sourceData.hasOwnProperty('sparse')) {
319
+ if (Object.hasOwn(sourceData, 'sparse')) {
323
320
  resolvedSparse = !!sourceData.sparse;
324
321
  } else {
325
322
  resolvedSparse = false;
326
323
  }
327
324
 
328
325
  // Get s3Profile if present
329
- if (sourceData.hasOwnProperty('s3Profile')) {
326
+ if (Object.hasOwn(sourceData, 's3Profile')) {
330
327
  resolvedS3Profile = sourceData.s3Profile;
331
328
  }
332
329
 
333
330
  // Get requestPayer if present
334
- if (sourceData.hasOwnProperty('requestPayer')) {
331
+ if (Object.hasOwn(sourceData, 'requestPayer')) {
335
332
  resolvedRequestPayer = !!sourceData.requestPayer;
336
333
  }
337
334
 
338
335
  // Get s3Region if present
339
- if (sourceData.hasOwnProperty('s3Region')) {
336
+ if (Object.hasOwn(sourceData, 's3Region')) {
340
337
  resolvedS3Region = sourceData.s3Region;
341
338
  }
342
339
 
@@ -427,7 +424,6 @@ async function start(opts) {
427
424
  startupPromises.push(serve_data.add(options, serving.data, item, id, opts));
428
425
  }
429
426
  if (options.serveAllStyles) {
430
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- options.paths.styles is from validated config
431
427
  fs.readdir(options.paths.styles, { withFileTypes: true }, (err, files) => {
432
428
  if (err) {
433
429
  return;
@@ -599,7 +595,6 @@ async function start(opts) {
599
595
  }
600
596
  }
601
597
  try {
602
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- templateFile is from internal templates or config-specified path
603
598
  const content = fs.readFileSync(templateFile, 'utf-8');
604
599
  const compiled = handlebars.compile(content.toString());
605
600
  app.get(urlPath, (req, res, next) => {
@@ -793,7 +788,7 @@ async function start(opts) {
793
788
  return null;
794
789
  }
795
790
 
796
- if (wmts.hasOwnProperty('serve_rendered') && !wmts.serve_rendered) {
791
+ if (Object.hasOwn(wmts, 'serve_rendered') && !wmts.serve_rendered) {
797
792
  return null;
798
793
  }
799
794
 
package/src/utils.js CHANGED
@@ -40,7 +40,6 @@ export function allowedScales(scale, maxScale = 9) {
40
40
  return 1;
41
41
  }
42
42
 
43
- // eslint-disable-next-line security/detect-non-literal-regexp -- maxScale is a number parameter, not user input
44
43
  const regex = new RegExp(`^[2-${maxScale}]x$`);
45
44
  if (!regex.test(scale)) {
46
45
  return null;
@@ -161,8 +160,7 @@ export function getTileUrls(
161
160
  const hostParts = urlObject.host.split('.');
162
161
  const relativeSubdomainsUsable =
163
162
  hostParts.length > 1 &&
164
- // eslint-disable-next-line security/detect-unsafe-regex -- Simple IPv4 validation, no nested quantifiers
165
- !/^([0-9]{1,3}\.){3}[0-9]{1,3}(\:[0-9]+)?$/.test(urlObject.host);
163
+ !/^([0-9]{1,3}\.){3}[0-9]{1,3}(:[0-9]+)?$/.test(urlObject.host);
166
164
  const newDomains = [];
167
165
  for (const domain of domains) {
168
166
  if (domain.indexOf('*') !== -1) {
@@ -253,7 +251,7 @@ export function fixTileJSONCenter(tileJSON) {
253
251
  export function readFile(filename) {
254
252
  return new Promise((resolve, reject) => {
255
253
  const sanitizedFilename = path.normalize(filename); // Normalize path, remove ..
256
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- filename is normalized and validated by caller
254
+
257
255
  fs.readFile(String(sanitizedFilename), (err, data) => {
258
256
  if (err) {
259
257
  reject(err);
@@ -276,7 +274,7 @@ export function readFile(filename) {
276
274
  async function getFontPbf(allowedFonts, fontPath, name, range, fallbacks) {
277
275
  // eslint-disable-next-line security/detect-object-injection -- name is validated font name from sanitizedName check
278
276
  if (!allowedFonts || (allowedFonts[name] && fallbacks)) {
279
- const fontMatch = name?.match(/^[\p{L}\p{N} \-\.~!*'()@&=+,#$\[\]]+$/u);
277
+ const fontMatch = name?.match(/^[\p{L}\p{N} \-.~!*'()@&=+,#$[\]]+$/u);
280
278
  const sanitizedName = fontMatch?.[0] || 'invalid';
281
279
  if (!name || typeof name !== 'string' || name.trim() === '' || !fontMatch) {
282
280
  console.error(
@@ -390,16 +388,13 @@ export async function getFontsPbf(
390
388
  export async function listFonts(fontPath) {
391
389
  const existingFonts = {};
392
390
 
393
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- fontPath is from validated config
394
391
  const files = await fsPromises.readdir(fontPath);
395
392
  for (const file of files) {
396
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- file is from readdir of validated fontPath
397
393
  const stats = await fsPromises.stat(path.join(fontPath, file));
398
394
  if (
399
395
  stats.isDirectory() &&
400
396
  (await existsP(path.join(fontPath, file, '0-255.pbf')))
401
397
  ) {
402
- // eslint-disable-next-line security/detect-object-injection -- file is from readdir, used as font name key
403
398
  existingFonts[path.basename(file)] = true;
404
399
  }
405
400
  }
@@ -415,7 +410,7 @@ export async function listFonts(fontPath) {
415
410
  export function isValidHttpUrl(string) {
416
411
  try {
417
412
  return httpTester.test(string);
418
- } catch (e) {
413
+ } catch {
419
414
  return false;
420
415
  }
421
416
  }
@@ -428,7 +423,7 @@ export function isValidHttpUrl(string) {
428
423
  export function isS3Url(string) {
429
424
  try {
430
425
  return s3Tester.test(string) || s3HttpTester.test(string);
431
- } catch (e) {
426
+ } catch {
432
427
  return false;
433
428
  }
434
429
  }
@@ -445,7 +440,7 @@ export function isValidRemoteUrl(string) {
445
440
  s3Tester.test(string) ||
446
441
  s3HttpTester.test(string)
447
442
  );
448
- } catch (e) {
443
+ } catch {
449
444
  return false;
450
445
  }
451
446
  }
@@ -458,7 +453,7 @@ export function isValidRemoteUrl(string) {
458
453
  export function isPMTilesProtocol(string) {
459
454
  try {
460
455
  return pmtilesTester.test(string);
461
- } catch (e) {
456
+ } catch {
462
457
  return false;
463
458
  }
464
459
  }
@@ -471,7 +466,7 @@ export function isPMTilesProtocol(string) {
471
466
  export function isMBTilesProtocol(string) {
472
467
  try {
473
468
  return mbtilesTester.test(string);
474
- } catch (e) {
469
+ } catch {
475
470
  return false;
476
471
  }
477
472
  }
@@ -487,15 +482,19 @@ export function isMBTilesProtocol(string) {
487
482
  */
488
483
  export async function fetchTileData(source, sourceType, z, x, y) {
489
484
  if (sourceType === 'pmtiles') {
490
- return await new Promise(async (resolve) => {
485
+ try {
491
486
  const tileinfo = await getPMtilesTile(source, z, x, y);
492
- if (!tileinfo?.data) return resolve(null);
493
- resolve({ data: tileinfo.data, headers: tileinfo.header });
494
- });
487
+ if (!tileinfo?.data) return null;
488
+ return { data: tileinfo.data, headers: tileinfo.header };
489
+ } catch (error) {
490
+ console.error('Error fetching PMTiles tile:', error);
491
+ return null;
492
+ }
495
493
  } else if (sourceType === 'mbtiles') {
496
- return await new Promise((resolve) => {
494
+ return new Promise((resolve) => {
497
495
  source.getTile(z, x, y, (err, tileData, tileHeader) => {
498
496
  if (err) {
497
+ console.error('Error fetching MBTiles tile:', err);
499
498
  return resolve(null);
500
499
  }
501
500
  resolve({ data: tileData, headers: tileHeader });
@@ -0,0 +1,241 @@
1
+ // test/static_images.js
2
+ import { describe, it } from 'mocha';
3
+ import { expect } from 'chai';
4
+ import supertest from 'supertest';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import sharp from 'sharp';
8
+ import pixelmatch from 'pixelmatch';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ const FIXTURES_DIR = path.join(__dirname, 'fixtures', 'visual');
14
+ const THRESHOLD = 0.1;
15
+ const MAX_DIFF_PIXELS = 100;
16
+
17
+ // Check for the environment variable to conditionally generate fixtures
18
+ const shouldGenerateFixtures = process.env.GENERATE_FIXTURES === 'true';
19
+
20
+ // --- Test Definitions ---
21
+ const tests = [
22
+ {
23
+ name: 'static-lat-lng',
24
+ // Test default center format (lng,lat,zoom)
25
+ url: '/styles/test-style/static/8.5375,47.379,12/400x300.png',
26
+ },
27
+ {
28
+ name: 'static-bearing',
29
+ // Test map bearing (rotation) at 180 degrees
30
+ url: '/styles/test-style/static/8.5375,47.379,12@180/400x300.png',
31
+ },
32
+ {
33
+ name: 'static-bearing-pitch',
34
+ // Test map bearing and pitch (3D tilt)
35
+ url: '/styles/test-style/static/8.5375,47.379,12@15,80/400x300.png',
36
+ },
37
+ {
38
+ name: 'static-pixel-ratio-2x',
39
+ // Test high-DPI rendering using @2x scale
40
+ url: '/styles/test-style/static/8.5375,47.379,11/200x150@2x.png',
41
+ },
42
+ {
43
+ name: 'path-auto',
44
+ // Test path rendering with simple coordinates and auto-centering
45
+ url: '/styles/test-style/static/auto/400x300.png?fill=%23ff000080&path=8.53180,47.38713|8.53841,47.38248|8.53320,47.37457',
46
+ },
47
+ {
48
+ name: 'encoded-path-auto',
49
+ // Test path rendering using encoded polyline and auto-centering
50
+ url: '/styles/test-style/static/auto/400x300.png?stroke=red&width=5&path=enc:wwg`Hyu}r@fNgn@hKyh@rR{ZlP{YrJmM`PJhNbH`P`VjUbNfJ|LzM~TtLnKxQZ',
51
+ },
52
+ {
53
+ name: 'linecap-linejoin-round-round',
54
+ // Test custom line styling: round linejoin and round linecap
55
+ url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?width=30&linejoin=round&linecap=round&path=enc:uhd`Hqk_s@kiA}nAnfAqpA',
56
+ },
57
+ {
58
+ name: 'linecap-linejoin-bevel-square',
59
+ // Test custom line styling: bevel linejoin and square linecap
60
+ url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?width=30&linejoin=bevel&linecap=square&path=enc:uhd`Hqk_s@kiA}nAnfAqpA',
61
+ },
62
+ {
63
+ name: 'static-markers',
64
+ // Test multiple markers with scale and offset options
65
+ url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?marker=8.531,47.38|marker-icon.png|scale:0.8&marker=8.545,47.375|marker-icon-2x.png|offset:5,-10',
66
+ },
67
+ {
68
+ name: 'static-bbox',
69
+ // Test area-based map rendering using a bounding box (bbox)
70
+ url: '/styles/test-style/static/8.5,47.35,8.6,47.4/400x300.png',
71
+ },
72
+ {
73
+ name: 'static-multiple-paths',
74
+ // Test rendering of multiple, individually styled path parameters
75
+ url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?path=stroke:blue|width:8|fill:none|8.53,47.38|8.54,47.385&path=stroke:red|width:3|fill:yellow|8.53,47.37|8.54,47.375',
76
+ },
77
+ {
78
+ name: 'static-path-latlng',
79
+ // Test path rendering when the 'latlng' parameter reverses coordinate order
80
+ url: '/styles/test-style/static/auto/400x300.png?latlng=true&path=47.38,8.53|47.385,8.54&fill=rgba(0,0,255,0.5)',
81
+ },
82
+ {
83
+ name: 'static-path-border-stroke',
84
+ // Test path border/halo functionality (line stroke with border halo)
85
+ url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?path=stroke:yellow|width:10|border:black|borderwidth:2|8.53,47.37|8.54,47.38|8.53,47.39',
86
+ },
87
+ {
88
+ name: 'static-path-border-isolated',
89
+ // Test path border/halo in isolation (only border, no stroke)
90
+ url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?path=border:black|borderwidth:10|8.53,47.37|8.54,47.38|8.53,47.39',
91
+ },
92
+ {
93
+ name: 'static-border-global',
94
+ // Test border functionality using global query parameters (less common, but valid)
95
+ url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?stroke=yellow&width=10&border=black&borderwidth=2&path=8.53,47.37|8.54,47.38|8.53,47.39',
96
+ },
97
+ ];
98
+
99
+ /**
100
+ * Loads an image buffer and extracts its raw pixel data.
101
+ * @param {Buffer} buffer The raw image data buffer (e.g., from an HTTP response).
102
+ * @returns {Promise<{data: Buffer, width: number, height: number}>} An object containing the raw RGBA pixel data, width, and height.
103
+ */
104
+ async function loadImageData(buffer) {
105
+ const image = sharp(buffer);
106
+ const { width, height } = await image.metadata();
107
+
108
+ // Get raw RGBA pixel data
109
+ const data = await image.ensureAlpha().raw().toBuffer();
110
+
111
+ return { data, width, height };
112
+ }
113
+
114
+ /**
115
+ * Fetches an image from the test server URL.
116
+ * @param {string} url The URL of the static image endpoint to fetch.
117
+ * @returns {Promise<Buffer>} A promise that resolves with the image buffer.
118
+ */
119
+ async function fetchImage(url) {
120
+ return new Promise((resolve, reject) => {
121
+ supertest(global.app)
122
+ .get(url)
123
+ .expect(200)
124
+ .expect('Content-Type', /image\/png/)
125
+ .end((err, res) => {
126
+ if (err) {
127
+ reject(err);
128
+ } else {
129
+ resolve(res.body);
130
+ }
131
+ });
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Compares two images (actual result vs. expected fixture) and counts the differing pixels.
137
+ * @param {Buffer} actualBuffer The buffer of the image rendered by the server.
138
+ * @param {string} expectedPath The file path to the expected fixture image.
139
+ * @returns {Promise<{numDiffPixels: number, diffBuffer: Buffer, width: number, height: number}>} Comparison results.
140
+ */
141
+ async function compareImages(actualBuffer, expectedPath) {
142
+ const actual = await loadImageData(actualBuffer);
143
+ const expectedBuffer = fs.readFileSync(expectedPath);
144
+ const expected = await loadImageData(expectedBuffer);
145
+
146
+ if (actual.width !== expected.width || actual.height !== expected.height) {
147
+ throw new Error(
148
+ `Image dimensions don't match: ${actual.width}x${actual.height} vs ${expected.width}x${expected.height}`,
149
+ );
150
+ }
151
+
152
+ const diffBuffer = Buffer.alloc(actual.width * actual.height * 4);
153
+ const numDiffPixels = pixelmatch(
154
+ actual.data,
155
+ expected.data,
156
+ diffBuffer,
157
+ actual.width,
158
+ actual.height,
159
+ { threshold: THRESHOLD },
160
+ );
161
+
162
+ return {
163
+ numDiffPixels,
164
+ diffBuffer,
165
+ width: actual.width,
166
+ height: actual.height,
167
+ };
168
+ }
169
+
170
+ // Conditional definition: Only define this suite if the GENERATE_FIXTURES environment variable is true
171
+ if (shouldGenerateFixtures) {
172
+ describe('GENERATE Visual Fixtures', function () {
173
+ this.timeout(10000);
174
+
175
+ it('should generate all fixture images', async function () {
176
+ fs.mkdirSync(FIXTURES_DIR, { recursive: true });
177
+ console.log(`\nGenerating fixtures to ${FIXTURES_DIR}\n`);
178
+
179
+ for (const { name, url } of tests) {
180
+ try {
181
+ const actualBuffer = await fetchImage(url);
182
+ const fixturePath = path.join(FIXTURES_DIR, `${name}.png`);
183
+ fs.writeFileSync(fixturePath, actualBuffer);
184
+ console.log(
185
+ `✓ Generated: ${name}.png (${actualBuffer.length} bytes)`,
186
+ );
187
+ } catch (error) {
188
+ console.error(`❌ Failed to generate ${name}:`, error.message);
189
+ throw error;
190
+ }
191
+ }
192
+
193
+ console.log(
194
+ `\n✓ Successfully generated ${tests.length} fixture images!\n`,
195
+ );
196
+ });
197
+ });
198
+ }
199
+
200
+ describe('Static Image Visual Regression Tests', function () {
201
+ this.timeout(10000);
202
+
203
+ tests.forEach(({ name, url }) => {
204
+ it(`should match expected output: ${name}`, async function () {
205
+ const expectedPath = path.join(FIXTURES_DIR, `${name}.png`);
206
+
207
+ if (!fs.existsSync(expectedPath)) {
208
+ this.skip();
209
+ return;
210
+ }
211
+
212
+ const actualBuffer = await fetchImage(url);
213
+ const { numDiffPixels, diffBuffer, width, height } = await compareImages(
214
+ actualBuffer,
215
+ expectedPath,
216
+ );
217
+
218
+ if (numDiffPixels > MAX_DIFF_PIXELS) {
219
+ const diffPath = path.join(FIXTURES_DIR, 'diffs', `${name}-diff.png`);
220
+ fs.mkdirSync(path.dirname(diffPath), { recursive: true });
221
+
222
+ await sharp(diffBuffer, {
223
+ raw: {
224
+ width,
225
+ height,
226
+ channels: 4,
227
+ },
228
+ })
229
+ .png()
230
+ .toFile(diffPath);
231
+
232
+ console.log(`Diff image saved to: ${diffPath}`);
233
+ }
234
+
235
+ expect(numDiffPixels).to.be.at.most(
236
+ MAX_DIFF_PIXELS,
237
+ `Expected at most ${MAX_DIFF_PIXELS} different pixels, but got ${numDiffPixels}`,
238
+ );
239
+ });
240
+ });
241
+ });