• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geosolutions-it / MapStore2 / 17429371957

03 Sep 2025 09:35AM UTC coverage: 76.742% (-0.01%) from 76.752%
17429371957

Pull #11424

github

web-flow
Merge fc53a6506 into 0b58fee13
Pull Request #11424: Proj4 upgrade and include support for "Grid Based Datum Adjustments" #11423

31404 of 48925 branches covered (64.19%)

12 of 27 new or added lines in 2 files covered. (44.44%)

2 existing lines in 2 files now uncovered.

38923 of 50719 relevant lines covered (76.74%)

37.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.2
/web/client/utils/VectorStyleUtils.js
1
/*
2
 * Copyright 2018, GeoSolutions Sas.
3
 * All rights reserved.
4
 *
5
 * This source code is licensed under the BSD-style license found in the
6
 * LICENSE file in the root directory of this source tree.
7
 */
8

9
import { isNil, flatten, isEmpty, castArray, max, isArray } from 'lodash';
10

11
import { set } from './ImmutableUtils';
12
import { colorToRgbaStr } from './ColorUtils';
13
import axios from 'axios';
14
import MarkerUtils from './MarkerUtils';
15

16
export const flattenFeatures = (features, mapFunc = feature => feature) => {
65✔
17
    // check if features is a collection object or an array of features/feature collection
18
    const parsedFeatures = isArray(features) ? features : features?.features;
104✔
19
    return flatten( (parsedFeatures || []).map((feature) => {
104!
20
        if (feature.type === 'FeatureCollection') {
79✔
21
            return feature.features || [];
19!
22
        }
23
        return [feature];
60✔
24
    })).map(mapFunc);
25
};
26

27
function initParserLib(mod, options) {
28
    const Parser = mod.default;
112✔
29
    return new Parser(options);
112✔
30
}
31

32
const StyleParsers = {
1✔
33
    'sld': () => import('@geosolutions/geostyler-sld-parser').then(initParserLib),
6✔
34
    'css': () => import('@geosolutions/geostyler-geocss-parser').then(initParserLib),
14✔
35
    'openlayers': () =>  import('./styleparser/OLStyleParser').then(initParserLib),
65✔
36
    '3dtiles': () => import('./styleparser/ThreeDTilesStyleParser').then(initParserLib),
1✔
37
    'cesium': () => import('./styleparser/CesiumStyleParser').then(initParserLib),
4✔
38
    'leaflet': () => import('./styleparser/LeafletStyleParser').then(initParserLib),
5✔
39
    'geostyler': () => import('./styleparser/GeoStylerStyleParser').then(initParserLib)
17✔
40
};
41

42
/**
43
 * checks if there is at least one attrbute in the object
44
 * @param {object} style the object to use for filtering the list of attributes
45
 * @param {string[]} attributes to use as filter list
46
 * @return {boolean} the result of the check
47
*/
48
export const isAttrPresent = (style = {}, attributes) => (attributes.filter(prop => !isNil(style[prop])).length > 0);
985!
49

50
/**
51
 * check if the style is assignable to an ol.Stroke style
52
 * @param {object} style to check
53
 * @param {string[]} attibutes of a stroke style
54
 * @return {boolean} if the style is compatible with an ol.Stroke
55
*/
56
export const isStrokeStyle = (style = {}, attributes = ["color", "opacity", "dashArray", "dashOffset", "lineCap", "lineJoin", "weight"]) => {
1!
57
    return isAttrPresent(style, attributes);
33✔
58
};
59

60
/**
61
 * check if the style is assignable to an ol.Fill style
62
 * @param {object} style to check
63
 * @param {string[]} attibutes of a fill style
64
 * @return {boolean} if the style is compatible with an ol.Fill style
65
*/
66
export const isFillStyle = (style = {}, attributes = ["fillColor", "fillOpacity"]) => {
1!
67
    return isAttrPresent(style, attributes);
31✔
68
};
69

70
/**
71
 * check if the style is assignable to an ol.Text style
72
 * @param {object} style to check
73
 * @param {string[]} attibutes of a text style
74
 * @return {boolean} if the style is compatible with an ol.Text style
75
*/
76
export const isTextStyle = (style = {}, attributes = ["label", "font", "fontFamily", "fontSize", "fontStyle", "fontWeight", "textAlign", "textRotationDeg"]) => {
1!
77
    return isAttrPresent(style, attributes);
31✔
78
};
79

80
/**
81
 * check if the style is assignable to an ol.Circle style
82
 * Note that sometimes circles can have a style similar to the polygons,
83
 * and that a property isCircle tells if it is an ol.Circle
84
 * @param {object} style to check
85
 * @param {string[]} attibutes of a circle style
86
 * @return {boolean} if the style is compatible with an ol.Circle style
87
*/
88
export const isCircleStyle = (style = {}, attributes = ["radius"]) => {
1!
89
    return isAttrPresent(style, attributes);
30✔
90
};
91

92
/**
93
 * check if the style is assignable to an ol.Icon style, as marker
94
 * @param {object} style to check
95
 * @param {string[]} attibutes of a marker style
96
 * @return {boolean} if the style is compatible with an ol.Icon style
97
*/
98
export const isMarkerStyle = (style = {}, attributes = ["iconGlyph", "iconShape", "iconUrl"]) => {
1✔
99
    return isAttrPresent(style, attributes);
84✔
100
};
101

102
/**
103
 * check if the style is assignable to an ol.Icon style, as symbol
104
 * @param {object} style to check
105
 * @param {string[]} attibutes of a symbol style
106
 * @return {boolean} if the style is compatible with an ol.Icon style
107
*/
108
export const isSymbolStyle = (style = {}, attributes = ["symbolUrl"]) => {
1!
109
    return isAttrPresent(style, attributes);
155✔
110
};
111

112

113
/**
114
 * gets a name from the style
115
 * @param {object} style to check
116
 * @return {string} the name
117
*/
118
export const getStylerTitle = (style = {}) => {
1!
119
    if (isMarkerStyle(style)) {
15✔
120
        return "Marker";
2✔
121
    }
122
    if (isSymbolStyle(style)) {
13✔
123
        return "Symbol";
2✔
124
    }
125
    if (isTextStyle(style) ) {
11✔
126
        return "Text";
2✔
127
    }
128
    if (isCircleStyle(style) || style.title === "Circle Style") {
9✔
129
        return "Circle";
3✔
130
    }
131
    if (isFillStyle(style) ) {
6✔
132
        return "Polygon";
2✔
133
    }
134
    if (isStrokeStyle(style) ) {
4✔
135
        return "Polyline";
2✔
136
    }
137
    return "";
2✔
138
};
139

140
/**
141
 * local cache for ol geometry functions
142
 * TODO needs maptype management (although, on leaflet they must interact
143
 * on the original  geojson feature)
144
*/
145
export let geometryFunctions = {
1✔
146
    "centerPoint": {
147
        type: "Point",
148
        func: () => {}
149
    },
150
    "lineToArc": {
151
        type: "LineString",
152
        func: () => {}
153
    },
154
    "startPoint": {
155
        type: "Point",
156
        func: () => {}
157
    },
158
    "endPoint": {
159
        type: "Point",
160
        func: () => {}
161
    }
162
};
163

164
/**
165
* getdata relative to geometry function in the local cache
166
* @param {string} functionName the function name
167
* @param {string} item to be returned
168
* @return {string|function} the geometry function or the type
169
*/
170
export const getGeometryFunction = (functionName, item) => {
1✔
171
    return geometryFunctions[functionName] && geometryFunctions[functionName][item];
5✔
172
};
173

174
/**
175
 * register new geometry function in the local cache
176
 * @param {string} functionName the function name
177
 * @param {function} func the implementation of the function
178
 * @param {type} geometry type associated with this function
179
*/
180
export const registerGeometryFunctions = (functionName, func, type) => {
1✔
181
    if (functionName && func && type) {
6✔
182
        geometryFunctions[functionName] = {func, type};
5✔
183
    } else {
184
        throw new Error("specify all the params: functionName, func, type");
1✔
185
    }
186
};
187

188
/**
189
 * add the opacity to an object color {r, g, b}
190
 * @param {object} color to update
191
 * @param {number} opacity to add
192
 * @return {object} color updated
193
*/
194
export const addOpacityToColor = (color = "#FFCC33", opacity = 0.2) => (set("a", opacity, color));
2!
195

196
/**
197
 * creates an has string from a string
198
 * https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
199
 * @param {string} str to hash
200
 * @return the hash number
201
*/
202
export const hashCode = function(str) {
1✔
203
    let hash = 0;
8✔
204
    let i;
205
    let chr;
206
    if (str.length === 0) {
8!
207
        return hash;
×
208
    }
209
    for (i = 0; i < str.length; i++) {
8✔
210
        chr = str.charCodeAt(i);
651✔
211
        hash = ((hash << 5) - hash) + chr;
651✔
212
        hash |= 0; // Convert to 32bit integer
651✔
213
    }
214
    return hash;
8✔
215
};
216

217
/**
218
 * SymbolsStyles local cache
219
*/
220
let SymbolsStyles = {
1✔
221
};
222

223

224
/**
225
* register a symbol style in a local cache
226
* @param {number} sha unique id generated from the json stringify of the style object
227
* @param {object} styleItems object to register {style, base64, svg} etc.
228
*/
229
export const registerStyle = (sha, styleItems) => {
1✔
230
    if (sha && styleItems) {
5✔
231
        SymbolsStyles[sha] = styleItems;
4✔
232
    } else {
233
        throw new Error("specify all the params: sha, style");
1✔
234
    }
235
};
236

237
/**
238
* reset Styles
239
*/
240
export const setSymbolsStyles = (symbStyles = {}) => {
1!
241
    SymbolsStyles = symbStyles;
33✔
242
};
243

244
/**
245
* get data relative to symbols style in the local caches
246
* @param {string} sha the sha generated from the style
247
* @param {string} item to be returned. Default is 'style'
248
* @return {object} the style object
249
*/
250
export const fetchStyle = (sha, item = "style") => {
1✔
251
    return SymbolsStyles[sha] && SymbolsStyles[sha][item];
3✔
252
};
253

254
/**
255
* get SymbolStyles
256
* @return {object} the object containing all the symbols Styles
257
*/
258
export const getSymbolsStyles = () => {
1✔
259
    return SymbolsStyles;
3✔
260
};
261

262
/**
263
* creates an hashCode after having stringified an object
264
* @param {object} style object
265
* @return {number} the sha
266
*/
267
export const hashAndStringify = (style) => {
1✔
268
    // style to has in case we want to exclude in future some props
269
    if (style) {
8✔
270
        return hashCode(JSON.stringify(style));
7✔
271
    }
272
    throw new Error("hashAndStringify: specify mandatory params: style");
1✔
273
};
274

275
/**
276
 * takes a dom element and parses it to a string
277
 * @param {object} domNode to parse
278
*/
279
export const domNodeToString = (domNode) => {
1✔
280
    let element = document.createElement("div");
1✔
281
    element.appendChild(domNode);
1✔
282
    return element.innerHTML;
1✔
283
};
284

285
export const createSvgUrl = (style = {}, url) => {
1✔
286
    /**
287
     * it loads an svg and it overrides some style option,
288
     * then it create and object URL that can be cached in a dictionary
289
    */
290
    // TODO think about adding a try catch for loading the not found icon
291
    return isSymbolStyle(style) && style.symbolUrl/* && !fetchStyle(hashAndStringify(style))*/ ?
5✔
292
        axios.get(url, { 'Content-Type': "image/svg+xml;charset=utf-8" })
293
            .then(response => {
294
                const DOMURL = window.URL || window.webkitURL || window;
1!
295
                const parser = new DOMParser();
1✔
296
                const doc = parser.parseFromString(response.data, 'image/svg+xml'); // create a dom element
1✔
297
                const svg = doc.firstElementChild; // fetch svg element
1✔
298

299
                // override attributes to the first svg tag
300
                svg.setAttribute("fill", style.fillColor || "#FFCC33");
1!
301
                svg.setAttribute("fill-opacity", !isNil(style.fillOpacity) ? style.fillOpacity : 0.2);
1!
302
                svg.setAttribute("stroke", colorToRgbaStr(style.color || "#FFCC33", !isNil(style.opacity) ? style.opacity : 1) );
1!
303
                svg.setAttribute("stroke-opacity", !isNil(style.opacity) ? style.opacity : 1);
1!
304
                svg.setAttribute("stroke-width", style.weight || 1);
1✔
305
                svg.setAttribute("width", style.size || 32);
1✔
306
                svg.setAttribute("height", style.size || 32);
1✔
307
                svg.setAttribute("stroke-dasharray", style.dashArray || "none");
1✔
308

309
                const svgBlob = new Blob([domNodeToString(svg)], { type: "image/svg+xml;charset=utf-8" });
1✔
310
                const symbolUrlCustomized = DOMURL.createObjectURL(svgBlob);
1✔
311

312

313
                // ******** retrieving the base64 conversion of svg ********
314
                let canvas = document.createElement('canvas');
1✔
315
                canvas.width = style.size;
1✔
316
                canvas.height = style.size;
1✔
317
                let ctx = canvas.getContext("2d");
1✔
318
                let icon = new Image();
1✔
319

320
                icon.src = symbolUrlCustomized;
1✔
321
                let base64 = "";
1✔
322
                let sha = hashAndStringify(style);
1✔
323
                icon.onload = () => {
1✔
324
                    try {
1✔
325
                    // only when loaded draw the customized svg
326
                        ctx.drawImage(icon, (canvas.width / 2) - (icon.width / 2), (canvas.height / 2) - (icon.height / 2));
1✔
327
                        base64 = canvas.toDataURL("image/png");
1✔
328
                        canvas = null;
1✔
329
                        registerStyle(sha, {style: {...style, symbolUrlCustomized}, base64});
1✔
330
                    } catch (e) {
331
                        return;
×
332
                    }
333
                };
334
                registerStyle(sha, {style: {...style, symbolUrlCustomized}, svg, base64});
1✔
335

336
                return symbolUrlCustomized;
1✔
337
            }).catch(()=> {
338
                return require('../product/assets/symbols/symbolMissing.svg');
1✔
339
            }) : new Promise((resolve) => {
340
            resolve(null);
3✔
341
        });
342
};
343

344
export const createStylesAsync = (styles = []) => {
1✔
345
    return styles.map(style => {
41✔
346
        return isSymbolStyle(style) && !fetchStyle(hashAndStringify(style)) ? createSvgUrl(style, style.symbolUrl || style.symbolUrlCustomized)
42!
347
            .then(symbolUrlCustomized => {
UNCOV
348
                return symbolUrlCustomized ? {...style, symbolUrlCustomized} : fetchStyle(hashAndStringify(style));
×
349
            }).catch(() => {
350
                return {...style, symbolUrlCustomized: require('../product/assets/symbols/symbolMissing.svg')};
×
351
            }) : new Promise((resolve) => {
352
            resolve(isSymbolStyle(style) ? fetchStyle(hashAndStringify(style)) : style);
42✔
353
        });
354
    });
355
};
356

357
/**
358
 * Import a style parser based on the format
359
 * @param  {string} format format encoding of the style: css, sld or openlayers
360
 * @return {promise} returns the parser instance if available
361
 */
362
export const getStyleParser = (format = 'sld') => {
1✔
363
    if (!StyleParsers[format]) {
114✔
364
        return Promise.resolve(null);
2✔
365
    }
366
    // import parser libraries dynamically
367
    return StyleParsers[format]();
112✔
368
};
369

370
function msStyleToSymbolizer(style, feature) {
371
    if (isTextStyle(style) && feature?.properties?.valueText) {
4!
372
        const fontParts = (style.font || '').split(' ');
×
373
        return Promise.resolve({
×
374
            kind: 'Text',
375
            label: feature.properties.valueText,
376
            font: [fontParts[fontParts.length - 1]],
377
            size: parseFloat(style.fontSize),
378
            fontStyle: style.fontStyle,
379
            fontWeight: style.fontWeight,
380
            color: style.fillColor,
381
            haloColor: style.color,
382
            haloWidth: 1,
383
            msHeightReference: 'none',
384
            msBringToFront: true
385
        });
386
    }
387
    if (style.symbolizerKind === 'Mark') {
4!
388
        return Promise.resolve({
×
389
            kind: 'Mark',
390
            color: style.fillColor,
391
            fillOpacity: style.fillOpacity,
392
            strokeColor: style.color,
393
            strokeOpacity: style.opacity,
394
            strokeWidth: style.weight,
395
            radius: style.radius ?? 10,
×
396
            wellKnownName: 'Circle',
397
            msHeightReference: 'none',
398
            msBringToFront: true
399
        });
400
    }
401
    if (isAttrPresent(style, ['iconUrl']) && !style.iconGlyph && !style.iconShape) {
4!
402
        return Promise.resolve({
×
403
            kind: 'Icon',
404
            image: style.iconUrl,
405
            size: max(style.iconSize || [32]),
×
406
            opacity: 1,
407
            rotate: 0,
408
            msHeightReference: 'none',
409
            msBringToFront: true,
410
            anchor: style?.anchor || 'bottom',      // add an option for anchor rather than bottom ic case of a passed param
×
411
            // only needed for get feature info marker
412
            ...(style.leaderLine && {
×
413
                msLeaderLineColor: '#333333',
414
                msLeaderLineOpacity: 1,
415
                msLeaderLineWidth: 1
416
            })
417
        });
418
    }
419
    if (isMarkerStyle(style)) {
4!
420
        return Promise.resolve({
×
421
            kind: 'Icon',
422
            image: MarkerUtils.extraMarkers.markerToDataUrl(style),
423
            size: 45,
424
            opacity: 1,
425
            rotate: 0,
426
            msHeightReference: 'none',
427
            msBringToFront: true
428
        });
429
    }
430
    if (isSymbolStyle(style)) {
4!
431
        const cachedSymbol = fetchStyle(hashAndStringify(style));
×
432
        return (
×
433
            cachedSymbol?.symbolUrlCustomized
×
434
                ? Promise.resolve(cachedSymbol?.symbolUrlCustomized)
435
                : createSvgUrl(style, style.symbolUrl || style.symbolUrlCustomized)
×
436
        )
437
            .then((symbolUrlCustomized) => {
438
                return {
×
439
                    kind: 'Icon',
440
                    image: symbolUrlCustomized,
441
                    size: style.size,
442
                    opacity: 1,
443
                    rotate: 0,
444
                    msHeightReference: 'none',
445
                    msBringToFront: true
446
                };
447
            })
448
            .catch(() => ({}));
×
449
    }
450
    if (isCircleStyle(style) || style.title === "Circle Style") {
4!
451
        return Promise.resolve({
×
452
            kind: 'Fill',
453
            color: style.fillColor,
454
            opacity: style.fillOpacity,
455
            fillOpacity: style.fillOpacity,
456
            outlineColor: style.color,
457
            outlineOpacity: style.opacity,
458
            outlineWidth: style.weight
459
        });
460
    }
461
    if (isFillStyle(style) ) {
4✔
462
        return Promise.resolve({
2✔
463
            kind: 'Fill',
464
            color: style.fillColor,
465
            opacity: style.fillOpacity,
466
            fillOpacity: style.fillOpacity,
467
            outlineColor: style.color,
468
            outlineOpacity: style.opacity,
469
            outlineWidth: style.weight
470
        });
471
    }
472
    if (isStrokeStyle(style) ) {
2✔
473
        return Promise.resolve({
1✔
474
            kind: 'Line',
475
            color: style.color,
476
            opacity: style.opacity,
477
            width: style.weight,
478
            ...(style?.dashArray && { dasharray: style.dashArray.map((value) => parseFloat(value)) })
×
479
        });
480
    }
481
    return Promise.resolve({});
1✔
482
}
483

484
function splitStyles(styles) {
485
    return flatten(styles.map(style => {
1✔
486
        return [
1✔
487
            ...(isAttrPresent(style, ['iconUrl'])
1!
488
                ? [
489
                    {
490

491
                        iconAnchor: style.iconAnchor,
492
                        iconSize: style.iconSize,
493
                        iconUrl: style.iconUrl,
494
                        popupAnchor: style.popupAnchor,
495
                        shadowSize: style.shadowSize,
496
                        shadowUrl: style.shadowUrl
497
                    }
498
                ]
499
                : []),
500

501
            ...(isFillStyle(style) && style.radius
3!
502
                ? [
503
                    {
504
                        symbolizerKind: 'Mark',
505
                        fillColor: style.fillColor,
506
                        fillOpacity: style.fillOpacity ?? 1,
×
507
                        color: style.color,
508
                        opacity: style.opacity ?? 1,
×
509
                        weight: style.weight ?? 1,
×
510
                        radius: style.radius ?? 10
×
511
                    }
512
                ]
513
                : []),
514
            ...(isStrokeStyle(style)
1!
515
                ? [
516
                    {
517
                        color: style.color,
518
                        opacity: style.opacity ?? 1,
1!
519
                        weight: style.weight ?? 1,
1!
520
                        dashArray: style.dashArray
521
                    }
522
                ]
523
                : []),
524
            ...(isFillStyle(style)
1!
525
                ? [
526
                    {
527
                        fillColor: style.fillColor,
528
                        fillOpacity: style.fillOpacity ?? 1,
1!
529
                        color: style.color,
530
                        opacity: style.opacity ?? 1,
1!
531
                        weight: style.weight ?? 1
1!
532
                    }
533
                ]
534
                : [])
535
        ];
536
    }));
537
}
538

539
export function layerToGeoStylerStyle(layer) {
540
    const features = flattenFeatures(layer?.features || []);
15✔
541
    const hasFeatureStyle = features.find(feature => !isEmpty(feature?.style || {}) && feature?.properties?.id);
15✔
542
    if (hasFeatureStyle) {
15✔
543
        const filteredFeatures = features.filter(feature => feature?.style && feature?.properties?.id);
2✔
544
        return Promise.all(
2✔
545
            flatten(filteredFeatures.map((feature) => {
546
                const styles = castArray(feature.style);
2✔
547
                return styles.map((style) =>
2✔
548
                    msStyleToSymbolizer(style, feature)
2✔
549
                        .then((symbolizer) => ({ symbolizer, filter: ['==', 'id', feature.properties.id] }))
2✔
550
                );
551
            }))
552
        ).then((symbolizers) => {
553
            return {
2✔
554
                format: 'geostyler',
555
                body: {
556
                    name: '',
557
                    rules: symbolizers.map(({ filter, symbolizer }) => ({
2✔
558
                        name: '',
559
                        filter,
560
                        symbolizers: [symbolizer]
561
                    }))
562
                },
563
                metadata: {
564
                    editorType: 'visual'
565
                }
566
            };
567
        });
568
    }
569
    if (!isEmpty(layer.style) && !layer?.style?.format && !layer?.style?.body) {
13✔
570
        return Promise.all(
1✔
571
            splitStyles(castArray(layer.style)).map((style) => msStyleToSymbolizer(style))
2✔
572
        )
573
            .then((symbolizers) => {
574
                const geometryTypeToKind = {
1✔
575
                    'point': ['Mark', 'Icon', 'Text'],
576
                    'linestring': ['Line'],
577
                    'polygon': ['Fill']
578
                };
579
                return {
1✔
580
                    format: 'geostyler',
581
                    body: {
582
                        name: '',
583
                        rules: symbolizers
584
                            .filter(({ kind }) => !geometryTypeToKind[layer.geometryType] || geometryTypeToKind[layer.geometryType].includes(kind))
2!
585
                            .map(symbolizer => ({
2✔
586
                                name: '',
587
                                symbolizers: [symbolizer]
588
                            }))
589
                    },
590
                    metadata: {
591
                        editorType: 'visual'
592
                    }
593
                };
594
            });
595
    }
596
    return Promise.resolve(layer.style);
12✔
597
}
598

599
export function getStyle({ style, features }, parserFormat) {
600
    const { format = 'geostyler', body } = style || {};
9!
601
    if (!format || !body) {
9!
602
        return Promise.resolve(null);
×
603
    }
604
    if (format === 'geostyler') {
9!
605
        return getStyleParser(parserFormat)
9✔
606
            .then((parser) => parser.writeStyle(body));
9✔
607
    }
608
    return Promise.all([
×
609
        getStyleParser(format),
610
        getStyleParser(parserFormat)
611
    ])
612
        .then(([inParser, outParser]) =>
613
            inParser
×
614
                .readStyle(body)
615
                .then(parsedStyle => outParser.writeStyle(parsedStyle, { features }))
×
616
        );
617
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc