tileserver-gl-light 5.5.0-pre.1 → 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.
- package/CHANGELOG.md +4 -2
- package/package.json +9 -9
- package/public/resources/maplibre-gl-inspect.js +2823 -2770
- package/public/resources/maplibre-gl-inspect.js.map +1 -1
- package/public/resources/maplibre-gl.css +1 -1
- package/public/resources/maplibre-gl.js +4 -4
- package/public/resources/maplibre-gl.js.map +1 -1
- package/src/main.js +16 -17
- package/src/pmtiles_adapter.js +3 -3
- package/src/promises.js +1 -1
- package/src/render.js +270 -93
- package/src/serve_data.js +6 -8
- package/src/serve_light.js +0 -1
- package/src/serve_rendered.js +372 -205
- package/src/server.js +22 -27
- package/src/utils.js +17 -18
- package/test/fixtures/visual/encoded-path-auto.png +0 -0
- package/test/fixtures/visual/linecap-linejoin-bevel-square.png +0 -0
- package/test/fixtures/visual/linecap-linejoin-round-round.png +0 -0
- package/test/fixtures/visual/path-auto.png +0 -0
- package/test/fixtures/visual/static-bbox.png +0 -0
- package/test/fixtures/visual/static-bearing-pitch.png +0 -0
- package/test/fixtures/visual/static-bearing.png +0 -0
- package/test/fixtures/visual/static-border-global.png +0 -0
- package/test/fixtures/visual/static-lat-lng.png +0 -0
- package/test/fixtures/visual/static-markers.png +0 -0
- package/test/fixtures/visual/static-multiple-paths.png +0 -0
- package/test/fixtures/visual/static-path-border-isolated.png +0 -0
- package/test/fixtures/visual/static-path-border-stroke.png +0 -0
- package/test/fixtures/visual/static-path-latlng.png +0 -0
- package/test/fixtures/visual/static-pixel-ratio-2x.png +0 -0
- 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
|
-
|
|
36
|
-
)
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
+
|
|
208
206
|
const styleFileData = await fs.promises.readFile(styleFile);
|
|
209
207
|
styleJSON = JSON.parse(styleFileData);
|
|
210
208
|
}
|
|
211
|
-
} catch
|
|
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 (
|
|
301
|
+
if (Object.hasOwn(sourceData, 'pmtiles')) {
|
|
305
302
|
currentFileType = 'pmtiles';
|
|
306
303
|
currentInputFileValue = sourceData.pmtiles;
|
|
307
|
-
} else if (
|
|
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 (
|
|
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 (
|
|
326
|
+
if (Object.hasOwn(sourceData, 's3Profile')) {
|
|
330
327
|
resolvedS3Profile = sourceData.s3Profile;
|
|
331
328
|
}
|
|
332
329
|
|
|
333
330
|
// Get requestPayer if present
|
|
334
|
-
if (
|
|
331
|
+
if (Object.hasOwn(sourceData, 'requestPayer')) {
|
|
335
332
|
resolvedRequestPayer = !!sourceData.requestPayer;
|
|
336
333
|
}
|
|
337
334
|
|
|
338
335
|
// Get s3Region if present
|
|
339
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
485
|
+
try {
|
|
491
486
|
const tileinfo = await getPMtilesTile(source, z, x, y);
|
|
492
|
-
if (!tileinfo?.data) return
|
|
493
|
-
|
|
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
|
|
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 });
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
});
|