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

geosolutions-it / MapStore2 / 30263629182

27 Jul 2026 11:52AM UTC coverage: 75.764% (-0.03%) from 75.789%
30263629182

Pull #12664

github

web-flow
Merge d4ed16c34 into 85310a94b
Pull Request #12664: Multi View Identify #12595

36066 of 56768 branches covered (63.53%)

165 of 229 new or added lines in 8 files covered. (72.05%)

116 existing lines in 5 files now uncovered.

44874 of 59229 relevant lines covered (75.76%)

123.14 hits per line

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

92.18
/web/client/utils/MapInfoUtils.js
1
/**
2
 * Copyright 2015-2016, 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 { INFO_FORMATS, INFO_FORMATS_BY_MIME_TYPE, JSON_MIME_TYPE, GEOJSON_MIME_TYPE, validator } from './FeatureInfoUtils';
10

11
import pointOnSurface from 'turf-point-on-surface';
12
import { findIndex, omit } from 'lodash';
13
import iconUrl from '../components/map/openlayers/img/marker-icon.png';
14
import JSONViewer from '../components/data/identify/viewers/JSONViewer';
15
import HTMLViewer from '../components/data/identify/viewers/HTMLViewer';
16
import TextViewer from '../components/data/identify/viewers/TextViewer';
17

18
import wfs from './mapinfo/wfs';
19
import wms from './mapinfo/wms';
20
import wmts from './mapinfo/wmts';
21
import vector from './mapinfo/vector';
22
import threeDTiles from './mapinfo/threeDTiles';
23
import model from './mapinfo/model';
24
import arcgis from './mapinfo/arcgis';
25
import cog from './mapinfo/cog';
26
import flatgeobuf from './mapinfo/flatgeobuf';
27
// TODO import only index in ./mapinfo
28

29
let MapInfoUtils;
30
/**
31
 * Map of info modes which are used to display feature info data (identify tools).
32
 * To be distinguished with INFO_FORMATS which is the map of mime types used in client server communication.
33
 * These are strictly a representation of the various ways that info data is visualized.
34
 * Has an N <=> N relationship with INFO_FORMATS.
35
 */
36
const INFO_VIEW_MODES = {
3✔
37
    TEXT: "TEXT",
38
    PROPERTIES: "PROPERTIES",
39
    HTML: "HTML",
40
    TEMPLATE: "TEMPLATE"
41
};
42

43
/**
44
 * @returns {object} Map of views which are used to display feature info data (identify tools).
45
 */
46
export const getInfoViewModes = () => {
3✔
47
    return {...INFO_VIEW_MODES};
141✔
48
};
49
/**
50
 * Given an `infoFormat` mime-type passed, it returns the default view mode (e.g. `PROPERTIES`, `HTML`, `TEXT`) for the format selected.
51
 *
52
 * @param {string} infoFormat the info format mime type.
53
 * @returns {string} the info view mode that is used for that info format.
54
 */
55
export const getDefaultInfoViewMode = (infoFormat) => {
3✔
56
    let infoView;
57
    switch (infoFormat) {
306✔
58
    case INFO_FORMATS.TEXT:
59
        infoView = INFO_VIEW_MODES.TEXT;
183✔
60
        break;
183✔
61
    case INFO_FORMATS.HTML:
62
        infoView = INFO_VIEW_MODES.HTML;
3✔
63
        break;
3✔
64
    case INFO_FORMATS.JSON:
65
        infoView = INFO_VIEW_MODES.PROPERTIES;
21✔
66
        break;
21✔
67
    case INFO_FORMATS.GEOJSON:
68
        infoView = INFO_VIEW_MODES.PROPERTIES;
75✔
69
        break;
75✔
70
    default:
71
        // TODO: re-assess leaving default null value, this way tests work but caller is burdened with fallback.
72
        infoView;
24✔
73
    }
74

75
    return infoView;
306✔
76
};
77
/**
78
 * Given a infoViewMode (e.g. "HTML", "PROPERTIES", "TEMPLATE"), returns the mime-type to use for the request for the given layer.
79
 *
80
 * @param {string} infoView the info view mode.
81
 * @param {array} layerInfoFormatCfg the layer supported GFI mime types.
82
 * @returns {string} the info format mime type.
83
 */
84
export const getInfoFormatByInfoView = (infoView, layerInfoFormatCfg) => {
3✔
85
    let infoFormat;
86
    switch (infoView) {
135!
87
    case INFO_VIEW_MODES.TEXT:
88
        infoFormat = INFO_FORMATS.TEXT;
54✔
89
        break;
54✔
90
    case INFO_VIEW_MODES.HTML:
91
        infoFormat = INFO_FORMATS.HTML;
27✔
92
        break;
27✔
93
    case INFO_VIEW_MODES.PROPERTIES:
94
    case INFO_VIEW_MODES.TEMPLATE:
95
        infoFormat = layerInfoFormatCfg?.includes(GEOJSON_MIME_TYPE) ? INFO_FORMATS.GEOJSON : INFO_FORMATS.JSON;
54!
96
        break;
54✔
97
    default:
98
        // TODO: re-assess leaving default null value, this way tests work but caller is burdened with fallback.
99
        infoFormat;
×
100
    }
101

102
    return infoFormat;
135✔
103
};
104

105
/**
106
 * specifies which info formats are currently supported
107
 */
108
//           default format ↴
109
export const SUPPORTED_FORMATS = ['TEXT', 'HTML', 'JSON', 'GEOJSON'];
3✔
110

111
export const EMPTY_RESOURCE_VALUE = 'NODATA';
3✔
112

113
/**
114
 * @return a filtered version of INFO_FORMATS object.
115
 * the returned object contains only keys that SUPPORTED_FORMATS contains.
116
 */
117
export const getAvailableInfoFormat = () => {
3✔
118
    return Object.keys(INFO_FORMATS).filter((k) => {
531✔
119
        return MapInfoUtils.SUPPORTED_FORMATS.indexOf(k) !== -1;
3,717✔
120
    }).reduce((prev, k) => {
121
        prev[k] = INFO_FORMATS[k];
2,124✔
122
        return prev;
2,124✔
123
    }, {});
124
};
125
/**
126
 * @return the label of an inputformat given the value
127
 */
128
export const getLabelFromValue = (value) => {
3✔
129
    return MapInfoUtils.getAvailableInfoFormatLabels().filter(info => INFO_FORMATS[info] === value)[0] || "TEXT";
36✔
130
};
131
/**
132
 * @return like getAvailableInfoFormat but return an array filled with the keys
133
 */
134
export const getAvailableInfoFormatLabels = () => {
3✔
135
    return Object.keys(MapInfoUtils.getAvailableInfoFormat());
48✔
136
};
137
/**
138
 * @return like getAvailableInfoFormat but return an array filled with the values
139
 */
140
export const getAvailableInfoFormatValues = () => {
3✔
141
    return Object.keys(MapInfoUtils.getAvailableInfoFormat()).map( label => {
66✔
142
        return INFO_FORMATS[label];
264✔
143
    });
144
};
145
/**
146
 * @return {string} the default info format value
147
 */
148
export const getDefaultInfoFormatValue = () => {
3✔
149
    return INFO_FORMATS[MapInfoUtils.SUPPORTED_FORMATS[0]];
45✔
150
};
151
/**
152
 * @param {object} param object map of params for a getFeatureInfo request.
153
 * @return {boolean} Check if param.info_format of param.outputFormat is set as json / geojson mime type.
154
 */
155
export const isDataFormat = (param) => {
3✔
156
    return param?.info_format === JSON_MIME_TYPE
39✔
157
        || param?.outputFormat === JSON_MIME_TYPE
158
        || param?.info_format === GEOJSON_MIME_TYPE
159
        || param?.outputFormat === GEOJSON_MIME_TYPE;
160
};
161
/**
162
 * returns feature info options of layer
163
 * @param layer {object} layer object
164
 * @return {object} feature info options
165
 */
166
export const getLayerFeatureInfo = (layer) => {
3✔
167
    return layer && layer.featureInfo && {...layer.featureInfo} || {};
1,665✔
168
};
169
/**
170
 * returns true if the layer has identify disabled.
171
 * It supports both the legacy HIDDEN format and the new disabled flag.
172
 * @param layer {object} layer object
173
 * @return {boolean} true if identify is disabled for the layer
174
 */
175
export const isLayerFeatureInfoDisabled = (layer) => {
3✔
176
    const featureInfo = getLayerFeatureInfo(layer);
852✔
177
    return !!featureInfo.disabled || featureInfo.format === 'HIDDEN';
852✔
178
};
179
/**
180
 * returns the normalized feature info views configured for a layer.
181
 * Legacy single featureInfo format is exposed as a single view.
182
 * @param layer {object} layer object
183
 * @param options {object} resolver options
184
 * @param options.defaultType {string} view type used when the layer has no saved configuration
185
 * @param options.includeDisabled {boolean} includes configured views while editing a disabled layer
186
 * @return {object[]} feature info views
187
 */
188
export const getLayerFeatureInfoViews = (layer, { defaultType, includeDisabled = false } = {}) => {
3✔
189
    const featureInfo = getLayerFeatureInfo(layer);
612✔
190
    if (isLayerFeatureInfoDisabled(layer) && !includeDisabled) {
612✔
191
        return [];
6✔
192
    }
193
    if (Array.isArray(featureInfo.views) && featureInfo.views.length) {
606✔
194
        return featureInfo.views.map((view, idx) => ({
255✔
195
            ...view,
196
            id: view.id || `view-${idx}`,
258✔
197
            title: view.title || 'Identify',
324✔
198
            type: view.type || view.format || INFO_VIEW_MODES.PROPERTIES
255!
199
        }));
200
    }
201
    if (featureInfo.format && featureInfo.format !== 'HIDDEN') {
405✔
202
        const { format, ...config } = featureInfo;
60✔
203
        return [{
60✔
204
            ...config,
205
            id: 'default',
206
            title: 'Identify',
207
            type: format
208
        }];
209
    }
210
    return defaultType ? [{
345✔
211
        id: 'default',
212
        title: 'Identify',
213
        type: defaultType
214
    }] : [];
215
};
216
/**
217
 * Extracts the proper mime time to use for the layer, given the passed props that determine the preferred type. This
218
 *  helps to convert, for instance, the mime-type set as default for the map (e.g. `application/json`) into the effective
219
 * mime type requested by the server (e.g. `application/geo+json`)
220
 * @param {object} layer the layer
221
 * @param props.format the preferred format, corresponding to the global settings information sheet field. it can be a mime type like `application/json`.
222
 * @return {string} the info format value from layer, otherwise the info format in settings
223
 */
224
export const getDefaultInfoFormatValueFromLayer = (layer, props) => {
3✔
225
    const featInfoFormat = getLayerFeatureInfoViews(layer)?.[0]?.type;
219✔
226
    if (featInfoFormat) {
219✔
227
        // When the user explicitly configures the format from the layer settings => feature info page, return directly from definition map.
228
        // Check if featInfoFormat is an actual view, otherwise retrieve infoFormat directly.
229
        return Object.values(getInfoViewModes()).includes(featInfoFormat)
138✔
230
            ? getInfoFormatByInfoView(featInfoFormat, layer.infoFormats)
231
            : getAvailableInfoFormat()[featInfoFormat];
232
    }
233
    if (props.format) {
81✔
234
        if (props.format === JSON_MIME_TYPE && layer.infoFormats && layer.infoFormats.includes(GEOJSON_MIME_TYPE)) {
57!
235
            // When global settings is configured for PROPERTIES (json), layer settings are not used and the layer.info_format configuration supports geo+json
236
            // then override global settings and set param.info_format to geo+json mime type explicitly.
237
            return GEOJSON_MIME_TYPE;
×
238
        }
239

240
        // otherwise, preserve and obey the global configration for getFeatureInfo mime type.
241
        return props.format;
57✔
242
    }
243

244
    // if global configration somehow fails provide a last fallback.
245
    return MapInfoUtils.getDefaultInfoFormatValue();
24✔
246
};
247
/**
248
 * @param {object} layer a layer object
249
 * @returns {object} the viewer configured for the layer. If viewer is not configured, it returns an empty object.
250
 */
251
export const getLayerFeatureInfoViewer = (layer) => {
3✔
252
    if (layer.featureInfo
192✔
253
        && layer.featureInfo.viewer) {
254
        return layer.featureInfo.viewer;
24✔
255
    }
256
    const viewer = getLayerFeatureInfoViews(layer)?.[0]?.viewer;
168✔
257
    if (viewer) {
168!
NEW
258
        return viewer;
×
259
    }
260
    return {};
168✔
261
};
262
export const clickedPointToGeoJson = (clickedPoint) => {
3✔
263
    if (!clickedPoint) {
24!
264
        return [];
×
265
    }
266
    if (clickedPoint.type === "Feature") {
24✔
267
        let features = [pointOnSurface(clickedPoint)];
12✔
268
        if (clickedPoint && clickedPoint.geometry && clickedPoint.geometry.type !== "Point") {
12✔
269
            features.push(clickedPoint);
3✔
270
        }
271
        return features;
12✔
272
    }
273

274
    if (clickedPoint.lng === undefined || clickedPoint.lat === undefined) {
12✔
275
        return clickedPoint.features || [];
6✔
276
    }
277
    return [
6✔
278
        ...(clickedPoint.features || []), // highlight features
12✔
279
        {
280
            id: "get-feature-info-point",
281
            type: "Feature",
282
            geometry: {
283
                type: 'Point',
284
                coordinates: [
285
                    parseFloat(clickedPoint.lng),
286
                    parseFloat(clickedPoint.lat),
287
                    ...(clickedPoint.height !== undefined
6!
288
                        ? [parseFloat(clickedPoint.height)]
289
                        : [])
290
                ]
291
            },
292
            properties: {
293
                id: 'get-feature-info-point'
294
            },
295
            style: [{
296
                iconUrl,
297
                iconAnchor: [12, 41], // in leaflet there is no anchor in fraction
298
                iconSize: [25, 41],
299
                leaderLine: clickedPoint.height !== undefined
300
            }]
301

302
        }
303
    ];
304
};
305
export const getMarkerLayer = (name, clickedMapPoint, styleName, otherParams, markerLabel) => {
3✔
306
    return {
24✔
307
        type: 'vector',
308
        visibility: true,
309
        queryable: false,
310
        name: name || "GetFeatureInfo",
24!
311
        styleName: styleName || "marker",
33✔
312
        label: markerLabel,
313
        features: MapInfoUtils.clickedPointToGeoJson(clickedMapPoint),
314
        ...otherParams
315
    };
316
};
317
/**
318
 * Creates GFI request and metadata for specific layer.
319
 * @param {object} layer the layer object
320
 * @param {object} options the options for the request
321
 * @param {string} options.format the format to use
322
 * @param {string} options.map the map object, with projection and
323
 * @param {object} options.point
324
 */
325
export const buildIdentifyRequest = (layer, options) => {
3✔
326
    if (MapInfoUtils.services[layer.type]) {
192!
327
        let infoFormat = MapInfoUtils.getDefaultInfoFormatValueFromLayer(layer, options);
192✔
328
        let viewer = MapInfoUtils.getLayerFeatureInfoViewer(layer);
192✔
329
        const featureInfo = MapInfoUtils.getLayerFeatureInfo(layer);
192✔
330
        return MapInfoUtils.services[layer.type].buildRequest(layer, options, infoFormat, viewer, featureInfo);
192✔
331
    }
332
    return {};
×
333
};
334
/**
335
 * Creates the minimum set of identify requests needed by the configured views.
336
 * Views using the same request share its response (for example PROPERTIES and TEMPLATE).
337
 * @param {object} layer the layer object
338
 * @param {object} options the identify request options
339
 * @return {object} effective views and deduplicated request configurations
340
 */
341
export const buildIdentifyRequestPlan = (layer, options) => {
3✔
342
    const defaultType = getDefaultInfoViewMode(options?.format || MapInfoUtils.getDefaultInfoFormatValue()) || INFO_VIEW_MODES.PROPERTIES;
63!
343
    const views = getLayerFeatureInfoViews(layer, { defaultType });
63✔
344
    if (isLayerFeatureInfoDisabled(layer)) {
63!
NEW
345
        return { views, requests: [] };
×
346
    }
347
    const requests = views.reduce((requestConfigs, view) => {
63✔
348
        // View-specific settings must not override the view currently being planned.
349
        const featureInfo = omit(layer.featureInfo || {}, ['format', 'template', 'viewer']);
81✔
350
        const requestConfig = buildIdentifyRequest({
81✔
351
            ...layer,
352
            featureInfo: {
353
                ...featureInfo,
354
                views: [view]
355
            }
356
        }, options);
357
        if (!requestConfig.url) {
81!
NEW
358
            return requestConfigs;
×
359
        }
360
        const requestKey = JSON.stringify({
81✔
361
            url: requestConfig.url,
362
            request: requestConfig.request
363
        });
364
        const existingRequest = requestConfigs.find(({ key }) => key === requestKey);
81✔
365
        if (existingRequest) {
81✔
366
            existingRequest.viewIds.push(view.id);
9✔
367
            return requestConfigs;
9✔
368
        }
369
        return [
72✔
370
            ...requestConfigs,
371
            {
372
                ...requestConfig,
373
                key: requestKey,
374
                viewIds: [view.id]
375
            }
376
        ];
377
    }, []);
378
    return { views, requests };
63✔
379
};
380
/**
381
 * Returns an Observable that emits the response when ready.
382
 * @param {object} layer the layer
383
 * @param {string} baseURL the URL for the request
384
 * @param {object} params for the request
385
 */
386
export const getIdentifyFlow = (layer, baseURL, params) => {
3✔
387
    if (MapInfoUtils.services[layer.type] && MapInfoUtils.services[layer.type].getIdentifyFlow) {
222!
388
        return MapInfoUtils.services[layer.type].getIdentifyFlow(layer, baseURL, params);
222✔
389
    }
390
    return null;
×
391
};
392

393
const deduceInfoFormat = (response) => {
3✔
394
    let infoFormat;
395
    // Handle WMS, WMTS
396
    if (response.queryParams && response.queryParams.hasOwnProperty('info_format')) {
294✔
397
        infoFormat = response.queryParams.info_format;
18✔
398
    }
399
    // handle WFS
400
    if (response.queryParams && response.queryParams.hasOwnProperty('outputFormat')) {
294!
401
        infoFormat = response.queryParams.outputFormat;
×
402
    }
403
    return infoFormat;
294✔
404
};
405

406
const determineValidatorFormat = (response, format) => {
3✔
407
    if (response.format) return response.format;
339✔
408

409
    const infoFormat = deduceInfoFormat(response);
294✔
410
    return INFO_FORMATS_BY_MIME_TYPE[infoFormat] || INFO_FORMATS_BY_MIME_TYPE[format];
294✔
411
};
412

413
const determineValidator = (response, format) => {
3✔
414
    const validatorFormat = determineValidatorFormat(response, format);
339✔
415
    return validator(validatorFormat);
339✔
416
};
417

418
export const getValidator = (format) => {
3✔
419
    const isValidResponse = (current) => {
426✔
420
        const viewResponses = Object.values(current?.viewResponses || {});
339✔
421
        if (viewResponses.length) {
339✔
422
            return viewResponses.some((response) => determineValidator(response, format).isValidResponse(response));
54✔
423
        }
424
        return determineValidator(current, format).isValidResponse(current);
285✔
425
    };
426
    return {
426✔
427
        getValidResponses: (responses) => {
428
            return responses.filter((current) => {
252✔
429
                if (current) {
294✔
430
                    return isValidResponse(current);
120✔
431
                }
432
                return false;
174✔
433
            });
434
        },
435
        getNoValidResponses: (responses) => {
436
            return responses.filter((current) => {
207✔
437
                if (current) {
225✔
438
                    return !isValidResponse(current);
219✔
439
                }
440
                return false;
6✔
441
            });
442
        }
443
    };
444
};
445
export const getViewers = () => {
3✔
446
    return {
3✔
447
        [INFO_VIEW_MODES.TEMPLATE]: JSONViewer,
448
        [INFO_VIEW_MODES.PROPERTIES]: JSONViewer,
449
        [INFO_VIEW_MODES.HTML]: HTMLViewer,
450
        [INFO_VIEW_MODES.TEXT]: TextViewer
451
    };
452
};
453
/**
454
 * @param {string} infoFormat the info format key corresponding to a specific mime type in INFO_FORMATS OR a custom viewer key set by the user.
455
 * @param {object} viewers a map of {infoFormat: viewerType} (see MapInfoUtils.getViewers).
456
 * @returns {jsx} the associated viewer component.
457
 */
458
export const getDefaultViewer = function(infoFormat, viewers = getViewers()) {
3!
459
    let isInfoKey = getAvailableInfoFormatLabels()?.includes(infoFormat);
36✔
460
    let isInfoValue = getAvailableInfoFormatValues()?.includes(infoFormat);
36✔
461
    if (isInfoKey) {
36!
462
        return viewers[getDefaultInfoViewMode(getAvailableInfoFormat()[infoFormat])];
×
463
    }
464
    if (isInfoValue) {
36✔
465
        return viewers[getDefaultInfoViewMode(infoFormat)];
33✔
466
    }
467

468
    return viewers[infoFormat];
3✔
469
};
470
export const defaultQueryableFilter = (l) => {
3✔
471
    return l.visibility &&
135✔
472
        MapInfoUtils.services[l.type] &&
473
        (l.queryable === undefined || l.queryable) &&
474
        l.group !== "background" &&
475
        !MapInfoUtils.isLayerFeatureInfoDisabled(l)
476
    ;
477
};
478
export const services = {
3✔
479
    'wfs': wfs,
480
    'wms': wms,
481
    'wmts': wmts,
482
    'vector': vector,
483
    '3dtiles': threeDTiles,
484
    'model': model,
485
    'arcgis': arcgis,
486
    'flatgeobuf': flatgeobuf,
487
    'cog': cog,
488
    'arcgis-feature': arcgis
489
};
490
/**
491
 * To get the custom viewer with the given type
492
 * This way you can extend the featureinfo with your custom viewers in external projects.
493
 * @param type {string} the string the component was registered with
494
 * @return {object} the registered component
495
 */
496
export const getViewer = (type) => {
3✔
497
    return !!MapInfoUtils.VIEWERS[type] ? MapInfoUtils.VIEWERS[type] : null;
6✔
498
};
499
/**
500
 * To register a custom viewer
501
 * This way you can extend the featureinfo with your custom viewers in external projects.
502
 * @param type {string} the string you want to register the component with
503
 * @param viewer {object} the component to register
504
 */
505
export const setViewer = (type, viewer) => {
3✔
506
    MapInfoUtils.VIEWERS[type] = viewer;
3✔
507
};
508
/**
509
 * returns new options filtered by include and exclude options
510
 * @param layer {object} layer object
511
 * @param includeOptions {array} options to include
512
 * @param excludeParams {array} options to exclude
513
 * @return {object} new filtered options
514
 */
515
export const filterRequestParams = (layer, includeOptions, excludeParams) => {
3✔
516
    let includeOpt = includeOptions || [];
66!
517
    let excludeList = excludeParams || [];
66!
518
    let options = Object.keys(layer).reduce((op, next) => {
66✔
519
        if (next !== "params" && includeOpt.indexOf(next) !== -1) {
393✔
520
            op[next] = layer[next];
12✔
521
        } else if (next === "params" && excludeList.length > 0) {
381!
522
            let params = layer[next];
×
523
            Object.keys(params).forEach((n) => {
×
524
                if (findIndex(excludeList, (el) => {return el === n; }) === -1) {
×
525
                    op[n] = params[n];
×
526
                }
527
            }, {});
528
        }
529
        return op;
393✔
530
    }, {});
531
    return options;
66✔
532
};
533

534
let rowViewers = {};
3✔
535

536
export const registerRowViewer = (name, options) => {
3✔
537
    rowViewers[name] = options;
12✔
538
};
539

540
export const getRowViewer = (name) => {
3✔
541
    return rowViewers[name];
9✔
542
};
543

544
MapInfoUtils = {
3✔
545
    SUPPORTED_FORMATS,
546
    getAvailableInfoFormatLabels,
547
    getAvailableInfoFormat,
548
    getDefaultInfoFormatValue,
549
    clickedPointToGeoJson,
550
    services,
551
    getDefaultInfoFormatValueFromLayer,
552
    getLayerFeatureInfoViewer,
553
    getLayerFeatureInfo,
554
    getLayerFeatureInfoViews,
555
    isLayerFeatureInfoDisabled,
556
    buildIdentifyRequestPlan,
557
    VIEWERS: {},
558
    registerRowViewer,
559
    getRowViewer
560
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc