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

naver / billboard.js / 6079269130

05 Sep 2023 01:40AM UTC coverage: 94.239% (-0.02%) from 94.261%
6079269130

push

github

netil
fix(shape): Fix circleY() undefined error

- Make .circleY() visible and included as part of common
- Move .circleY() to shape.ts file

Fix #3388

5992 of 6639 branches covered (0.0%)

Branch coverage included in aggregate %.

10 of 12 new or added lines in 2 files covered. (83.33%)

22 existing lines in 2 files now uncovered.

7732 of 7924 relevant lines covered (97.58%)

21314.59 hits per line

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

94.82
/src/ChartInternal/shape/shape.ts
1
/**
2
 * Copyright (c) 2017 ~ present NAVER Corp.
3
 * billboard.js project is licensed under the MIT license
4
 */
5
import {
6
        curveStepBefore as d3CurveStepBefore,
7
        curveStepAfter as d3CurveStepAfter,
8
        curveBasisClosed as d3CurveBasisClosed,
9
        curveBasisOpen as d3CurveBasisOpen,
10
        curveBasis as d3CurveBasis,
11
        curveBundle as d3CurveBundle,
12
        curveCardinalClosed as d3CurveCardinalClosed,
13
        curveCardinalOpen as d3CurveCardinalOpen,
14
        curveCardinal as d3CurveCardinal,
15
        curveCatmullRomClosed as d3CurveCatmullRomClosed,
16
        curveCatmullRomOpen as d3CurveCatmullRomOpen,
17
        curveCatmullRom as d3CurveCatmullRom,
18
        curveLinearClosed as d3CurveLinearClosed,
19
        curveLinear as d3CurveLinear,
20
        curveMonotoneX as d3CurveMonotoneX,
21
        curveMonotoneY as d3CurveMonotoneY,
22
        curveNatural as d3CurveNatural,
23
        curveStep as d3CurveStep
24
} from "d3-shape";
25
import {select as d3Select} from "d3-selection";
26
import type {d3Selection} from "../../../types/types";
27
import CLASS from "../../config/classes";
28
import {capitalize, getPointer, getRectSegList, getUnique, isObjectType, isNumber, isValue, isUndefined, notEmpty} from "../../module/util";
29
import type {IDataRow, IDataIndice, TIndices} from "../data/IData";
30

31
/**
32
 * Get grouped data point function for y coordinate
33
 * - Note: Grouped(stacking) works only for line and bar types
34
 * @param {object} d data vlaue
35
 * @returns {Function|undefined}
36
 * @private
37
 */
38
function getGroupedDataPointsFn(d) {
39
        const $$ = this;
1,566✔
40
        let fn;
41

42
        if ($$.isLineType(d)) {
1,566!
43
                fn = $$.generateGetLinePoints($$.getShapeIndices($$.isLineType));
1,566✔
NEW
44
        } else if ($$.isBarType(d)) {
×
NEW
45
                fn = $$.generateGetBarPoints($$.getShapeIndices($$.isBarType));
×
46
        }
47

48
        return fn;
1,566✔
49
}
50

51
export interface IOffset {
52
        _$width: number;
53
        _$total: number[]
54
}
55

56
export default {
57
        /**
58
         * Get the shape draw function
59
         * @returns {object}
60
         * @private
61
         */
62
        getDrawShape() {
7,317✔
63
                type TShape = {
64
                        area?: any;
65
                        bar?: any;
66
                        line?: any;
67
                };
68

69
                const $$ = this;
7,317✔
70
                const isRotated = $$.config.axis_rotated;
7,317✔
71
                const {hasRadar, hasTreemap} = $$.state;
7,317✔
72
                const shape = {type: <TShape>{}, indices: <TShape>{}, pos: {}};
7,317✔
73

74
                !hasTreemap && ["bar", "candlestick", "line", "area"].forEach(v => {
29,064✔
75
                        const name = capitalize(/^(bubble|scatter)$/.test(v) ? "line" : v);
29,064!
76

77
                        if ($$.hasType(v) || $$.hasTypeOf(name) || (
29,064✔
78
                                v === "line" && ($$.hasType("bubble") || $$.hasType("scatter"))
79
                        )) {
80
                                const indices = $$.getShapeIndices($$[`is${name}Type`]);
7,293✔
81
                                const drawFn = $$[`generateDraw${name}`];
7,293✔
82

83
                                shape.indices[v] = indices;
7,293✔
84
                                shape.type[v] = drawFn ? drawFn.bind($$)(indices, false) : undefined;
7,293!
85
                        }
86
                });
87

88
                if (!$$.hasArcType() || hasRadar || hasTreemap) {
7,317✔
89
                        let cx;
90
                        let cy;
91

92
                        // generate circle x/y functions depending on updated params
93
                        if (!hasTreemap) {
6,609✔
94
                                cx = hasRadar ? $$.radarCircleX : (isRotated ? $$.circleY : $$.circleX);
6,558✔
95
                                cy = hasRadar ? $$.radarCircleY : (isRotated ? $$.circleX : $$.circleY);
6,558✔
96
                        }
97

98
                        shape.pos = {
6,609✔
99
                                xForText: $$.generateXYForText(shape.indices, true),
100
                                yForText: $$.generateXYForText(shape.indices, false),
101
                                cx: (cx || function() {}).bind($$),
6,660✔
102
                                cy: (cy || function() {}).bind($$)
6,660✔
103
                        };
104
                }
105

106
                return shape;
7,317✔
107
        },
108

109
        /**
110
         * Get shape's indices according it's position within each axis tick.
111
         *
112
         * From the below example, indices will be:
113
         * ==> {data1: 0, data2: 0, data3: 1, data4: 1, __max__: 1}
114
         *
115
         *        data1 data3   data1 data3
116
         *        data2 data4   data2 data4
117
         *        -------------------------
118
         *                 0             1
119
         * @param {Function} typeFilter Chart type filter function
120
         * @returns {object} Indices object with its position
121
         */
122
        getShapeIndices(typeFilter): TIndices {
8,859✔
123
                const $$ = this;
8,859✔
124
                const {config} = $$;
8,859✔
125
                const xs = config.data_xs;
8,859✔
126
                const hasXs = notEmpty(xs);
8,859✔
127
                const indices: TIndices = {};
8,859✔
128
                let i: any = hasXs ? {} : 0;
8,859✔
129

130
                if (hasXs) {
8,859✔
131
                        getUnique(Object.keys(xs).map(v => xs[v]))
276✔
132
                                .forEach(v => {
249✔
133
                                        i[v] = 0;
249✔
134
                                        indices[v] = {};
249✔
135
                                });
136
                }
137

138
                $$.filterTargetsToShow($$.data.targets.filter(typeFilter, $$))
8,859✔
139
                        .forEach(d => {
19,719✔
140
                                const xKey = d.id in xs ? xs[d.id] : "";
19,719✔
141
                                const ind = xKey ? indices[xKey] : indices;
19,719✔
142

143
                                for (let j = 0, groups; (groups = config.data_groups[j]); j++) {
19,719✔
144
                                        if (groups.indexOf(d.id) < 0) {
11,592✔
145
                                                continue;
2,553✔
146
                                        }
147

148
                                        for (let k = 0, key; (key = groups[k]); k++) {
9,039✔
149
                                                if (key in ind) {
15,294✔
150
                                                        ind[d.id] = ind[key];
6,201✔
151
                                                        break;
6,201✔
152
                                                }
153

154
                                                // for same grouped data, add other data to same indices
155
                                                if (d.id !== key && xKey) {
9,093✔
156
                                                        ind[key] = ind[d.id] ?? i[xKey];
30!
157
                                                }
158
                                        }
159
                                }
160

161
                                if (isUndefined(ind[d.id])) {
19,719✔
162
                                        ind[d.id] = xKey ? i[xKey]++ : i++;
13,518✔
163
                                        ind.__max__ = (xKey ? i[xKey] : i) - 1;
13,518✔
164
                                }
165
                        });
166

167
                return indices;
8,859✔
168
        },
169

170
        /**
171
         * Get indices value based on data ID value
172
         * @param {object} indices Indices object
173
         * @param {object} d Data row
174
         * @param {string} caller Caller function name (Used only for 'sparkline' plugin)
175
         * @returns {object} Indices object
176
         * @private
177
         */
178
        getIndices(indices: TIndices, d: IDataRow, caller?: string): IDataIndice { // eslint-disable-line
58,476✔
179
                const $$ = this;
58,476✔
180
                const {data_xs: xs, bar_indices_removeNull: removeNull} = $$.config;
58,476✔
181
                const {id, index} = d;
58,476✔
182

183
                if ($$.isBarType(id) && removeNull) {
58,476✔
184
                        const ind = {} as IDataIndice;
90✔
185

186
                        // redefine bar indices order
187
                        $$.getAllValuesOnIndex(index, true)
90✔
188
                                .forEach((v, i) => {
180✔
189
                                        ind[v.id] = i;
180✔
190
                                        ind.__max__ = i;
180✔
191
                                });
192

193
                        return ind;
90✔
194
                }
195

196
                return notEmpty(xs) ?
58,386✔
197
                        indices[xs[id]] : indices;
58,386✔
198
        },
199

200
        /**
201
         * Get indices max number
202
         * @param {object} indices Indices object
203
         * @returns {number} Max number
204
         * @private
205
         */
206
        getIndicesMax(indices: TIndices | IDataIndice): number {
5,313✔
207
                return notEmpty(this.config.data_xs) ?
5,313✔
208
                        // if is multiple xs, return total sum of xs' __max__ value
209
                        Object.keys(indices)
5,313✔
210
                                .map(v => indices[v].__max__ || 0)
324✔
211
                                .reduce((acc, curr) => acc + curr) : (indices as IDataIndice).__max__;
153✔
212
        },
213

214
        getShapeX(offset: IOffset, indices, isSub?: boolean): (d) => number {
23,787✔
215
                const $$ = this;
23,787✔
216
                const {config, scale} = $$;
23,787✔
217
                const currScale = isSub ? scale.subX : (scale.zoom || scale.x);
23,787✔
218
                const barOverlap = config.bar_overlap;
23,787✔
219
                const barPadding = config.bar_padding;
23,787✔
220
                const sum = (p, c) => p + c;
23,787✔
221

222
                // total shapes half width
223
                const halfWidth = isObjectType(offset) && (
23,787✔
224
                        offset._$total.length ? offset._$total.reduce(sum) / 2 : 0
360✔
225
                );
226

227
                return d => {
29,241✔
228
                        const ind = $$.getIndices(indices, d, "getShapeX");
29,241✔
229
                        const index = d.id in ind ? ind[d.id] : 0;
29,241✔
230
                        const targetsNum = (ind.__max__ || 0) + 1;
29,241✔
231
                        let x = 0;
29,241✔
232

233
                        if (notEmpty(d.x)) {
29,241!
234
                                const xPos = currScale(d.x, true);
29,241✔
235

236
                                if (halfWidth) {
29,241✔
237
                                        const offsetWidth = offset[d.id] || offset._$width;
342!
238

239
                                        x = barOverlap ?
342✔
240
                                                xPos - offsetWidth / 2 :
342✔
241
                                                xPos - offsetWidth + offset._$total.slice(0, index + 1).reduce(sum) - halfWidth;
242
                                } else {
243
                                        x = xPos - (isNumber(offset) ? offset : offset._$width) *
28,899✔
244
                                                (targetsNum / 2 - (
245
                                                        barOverlap ? 1 : index
28,899!
246
                                                ));
247
                                }
248
                        }
249

250
                        // adjust x position for bar.padding option
251
                        if (offset && x && targetsNum > 1 && barPadding) {
29,241✔
252
                                if (index) {
864✔
253
                                        x += barPadding * index;
432✔
254
                                }
255

256
                                if (targetsNum > 2) {
864!
UNCOV
257
                                        x -= (targetsNum - 1) * barPadding / 2;
×
258
                                } else if (targetsNum === 2) {
864!
259
                                        x -= barPadding / 2;
864✔
260
                                }
261
                        }
262

263
                        return x;
29,241✔
264
                };
265
        },
266

267
        getShapeY(isSub?: boolean): Function {
23,787✔
268
                const $$ = this;
23,787✔
269
                const isStackNormalized = $$.isStackNormalized();
23,787✔
270

271
                return d => {
30,015✔
272
                        let {value} = d;
30,015✔
273

274
                        if (isNumber(d)) {
30,015✔
275
                                value = d;
1,032✔
276
                        } else if (isStackNormalized) {
28,983✔
277
                                value = $$.getRatio("index", d, true);
270✔
278
                        } else if ($$.isBubbleZType(d)) {
28,713!
UNCOV
279
                                value = $$.getBubbleZData(d.value, "y");
×
280
                        } else if ($$.isBarRangeType(d)) {
28,713✔
281
                                // TODO use range.getEnd() like method
282
                                value = value[1];
117✔
283
                        }
284

285
                        return $$.getYScaleById(d.id, isSub)(value);
30,015✔
286
                };
287
        },
288

289
        /**
290
         * Get shape based y Axis min value
291
         * @param {string} id Data id
292
         * @returns {number}
293
         * @private
294
         */
295
        getShapeYMin(id: string): number {
296
                const $$ = this;
68,040✔
297
                const axisId = $$.axis.getId(id);
68,040✔
298
                const scale = $$.scale[axisId];
68,040✔
299
                const [yMin] = scale.domain();
68,040✔
300
                const inverted = $$.config[`axis_${axisId}_inverted`];
68,040✔
301

302
                return !$$.isGrouped(id) && !inverted && yMin > 0 ? yMin : 0;
68,040✔
303
        },
304

305
        /**
306
         * Get Shape's offset data
307
         * @param {Function} typeFilter Type filter function
308
         * @returns {object}
309
         * @private
310
         */
311
        getShapeOffsetData(typeFilter) {
23,787✔
312
                const $$ = this;
23,787✔
313
                const targets = $$.orderTargets($$.filterTargetsToShow($$.data.targets.filter(typeFilter, $$)));
23,787✔
314
                const isStackNormalized = $$.isStackNormalized();
23,787✔
315

316
                const shapeOffsetTargets = targets.map(target => {
99,660✔
317
                        let rowValues = target.values;
49,830✔
318
                        const values = {};
49,830✔
319

320
                        if ($$.isStepType(target)) {
49,830✔
321
                                rowValues = $$.convertValuesToStep(rowValues);
1,740✔
322
                        }
323

324
                        const rowValueMapByXValue = rowValues.reduce((out, d) => {
265,296✔
325
                                const key = Number(d.x);
265,296✔
326

327
                                out[key] = d;
265,296✔
328
                                values[key] = isStackNormalized ? $$.getRatio("index", d, true) : d.value;
265,296✔
329

330
                                return out;
265,296✔
331
                        }, {});
332

333
                        return {
49,830✔
334
                                id: target.id,
335
                                rowValues,
336
                                rowValueMapByXValue,
337
                                values
338
                        };
339
                });
340
                const indexMapByTargetId = targets.reduce((out, {id}, index) => {
49,830✔
341
                        out[id] = index;
49,830✔
342
                        return out;
49,830✔
343
                }, {});
344

345
                return {indexMapByTargetId, shapeOffsetTargets};
23,787✔
346
        },
347

348
        getShapeOffset(typeFilter, indices, isSub?: boolean): Function {
23,787✔
349
                const $$ = this;
23,787✔
350
                const {shapeOffsetTargets, indexMapByTargetId} = $$.getShapeOffsetData(typeFilter);
23,787✔
351
                const groupsZeroAs = $$.config.data_groupsZeroAs;
23,787✔
352

353
                return (d, idx) => {
58,488✔
354
                        const {id, value, x} = d;
29,244✔
355
                        const ind = $$.getIndices(indices, d);
29,244✔
356
                        const scale = $$.getYScaleById(id, isSub);
29,244✔
357

358
                        if ($$.isBarRangeType(d)) {
29,244✔
359
                                // TODO use range.getStart()
360
                                return scale(value[0]);
117✔
361
                        }
362

363
                        const dataXAsNumber = Number(x);
29,127✔
364
                        const y0 = scale(groupsZeroAs === "zero" ? 0 : $$.getShapeYMin(id));
29,127✔
365
                        let offset = y0;
29,127✔
366

367
                        shapeOffsetTargets
29,127✔
368
                                .filter(t => t.id !== id && ind[t.id] === ind[id])
128,109✔
369
                                .forEach(t => {
78,804✔
370
                                        const {id: tid, rowValueMapByXValue, rowValues, values: tvalues} = t;
78,804✔
371

372
                                        // for same stacked group (ind[tid] === ind[id])
373
                                        if (indexMapByTargetId[tid] < indexMapByTargetId[id]) {
78,804✔
374
                                                const rValue = tvalues[dataXAsNumber];
40,734✔
375
                                                let row = rowValues[idx];
40,734✔
376

377
                                                // check if the x values line up
378
                                                if (!row || Number(row.x) !== dataXAsNumber) {
40,734✔
379
                                                        row = rowValueMapByXValue[dataXAsNumber];
35,904✔
380
                                                }
381

382
                                                if (row?.value * value >= 0 && isNumber(rValue)) {
40,734✔
383
                                                        const addOffset = value === 0 ? (
17,469✔
384
                                                                (groupsZeroAs === "positive" && rValue > 0) ||
18,363✔
385
                                                                (groupsZeroAs === "negative" && rValue < 0)
386
                                                        ) : true;
387

388
                                                        if (addOffset) {
17,469✔
389
                                                                offset += scale(rValue) - y0;
17,289✔
390
                                                        }
391
                                                }
392
                                        }
393
                                });
394

395
                        return offset;
29,127✔
396
                };
397
        },
398

399
        /**
400
         * Get data's y coordinate
401
         * @param {object} d Target data
402
         * @param {number} i Index number
403
         * @returns {number} y coordinate
404
         * @private
405
         */
406
        circleY(d: IDataRow, i: number): number {
407
                const $$ = this;
53,097✔
408
                const id = d.id;
53,097✔
409
                let points;
410

411
                if ($$.isGrouped(id)) {
53,097✔
412
                        points = getGroupedDataPointsFn.bind($$)(d);
1,566✔
413
                }
414

415
                return points ?
53,097✔
416
                        points(d, i)[0][1] :
53,097✔
417
                        $$.getYScaleById(id)($$.getBaseValue(d));
418
        },
419

420
        getBarW(type, axis, targetsNum: number): number | IOffset {
5,313✔
421
                const $$ = this;
5,313✔
422
                const {config, org, scale} = $$;
5,313✔
423
                const maxDataCount = $$.getMaxDataCount();
5,313✔
424
                const isGrouped = type === "bar" && config.data_groups.length;
5,313✔
425
                const configName = `${type}_width`;
5,313✔
426

427
                const tickInterval = scale.zoom && !$$.axis.isCategorized() ?
5,313✔
428
                        (org.xDomain.map(v => scale.zoom(v))
180✔
429
                                .reduce((a, c) => Math.abs(a) + c) / maxDataCount
90✔
430
                        ) : axis.tickInterval(maxDataCount);
431

432
                const getWidth = (id?: string) => {
5,511✔
433
                        const width = id ? config[configName][id] : config[configName];
5,511✔
434
                        const ratio = id ? width.ratio : config[`${configName}_ratio`];
5,511✔
435
                        const max = id ? width.max : config[`${configName}_max`];
5,511✔
436
                        const w = isNumber(width) ?
5,511✔
437
                                width : targetsNum ? (tickInterval * ratio) / targetsNum : 0;
10,725✔
438

439
                        return max && w > max ? max : w;
5,511✔
440
                };
441

442
                let result = getWidth();
5,313✔
443

444
                if (!isGrouped && isObjectType(config[configName])) {
5,313✔
445
                        result = {_$width: result, _$total: []};
360✔
446

447
                        $$.filterTargetsToShow($$.data.targets).forEach(v => {
666✔
448
                                if (config[configName][v.id]) {
666✔
449
                                        result[v.id] = getWidth(v.id);
198✔
450
                                        result._$total.push(result[v.id] || result._$width);
198!
451
                                }
452
                        });
453
                }
454

455
                return result;
5,313✔
456
        },
457

458
        /**
459
         * Get shape element
460
         * @param {string} shapeName Shape string
461
         * @param {number} i Index number
462
         * @param {string} id Data series id
463
         * @returns {d3Selection}
464
         * @private
465
         */
466
        getShapeByIndex(shapeName: string, i: number, id?: string): d3Selection {
1,611✔
467
                const $$ = this;
1,611✔
468
                const {$el} = $$;
1,611✔
469
                const suffix = (isValue(i) ? `-${i}` : ``);
1,611✔
470
                let shape = $el[shapeName];
1,611✔
471

472
                // filter from shape reference if has
473
                if (shape && !shape.empty()) {
1,611✔
474
                        shape = shape
1,545✔
475
                                .filter(d => (id ? d.id === id : true))
14,760✔
476
                                .filter(d => (isValue(i) ? d.index === i : true));
13,719✔
477
                } else {
478
                        shape = (id ? $el.main
66!
479
                                .selectAll(`.${CLASS[`${shapeName}s`]}${$$.getTargetSelectorSuffix(id)}`) : $el.main)
480
                                .selectAll(`.${CLASS[shapeName]}${suffix}`);
481
                }
482

483
                return shape;
1,611✔
484
        },
485

486
        isWithinShape(that, d): boolean {
487
                const $$ = this;
636✔
488
                const shape = d3Select(that);
636✔
489
                let isWithin;
490

491
                if (!$$.isTargetToShow(d.id)) {
636!
UNCOV
492
                        isWithin = false;
×
493
                } else if ($$.hasValidPointType?.(that.nodeName)) {
636✔
494
                        isWithin = $$.isStepType(d) ?
414✔
495
                                $$.isWithinStep(that, $$.getYScaleById(d.id)(d.value)) :
414!
496
                                $$.isWithinCircle(that, $$.isBubbleType(d) ? $$.pointSelectR(d) * 1.5 : 0);
414✔
497
                } else if (that.nodeName === "path") {
222!
498
                        isWithin = shape.classed(CLASS.bar) ? $$.isWithinBar(that) : true;
222!
499
                }
500

501
                return isWithin;
636✔
502
        },
503

504
        getInterpolate(d) {
505
                const $$ = this;
9,951✔
506
                const interpolation = $$.getInterpolateType(d);
9,951✔
507

508
                return {
9,951✔
509
                        "basis": d3CurveBasis,
510
                        "basis-closed": d3CurveBasisClosed,
511
                        "basis-open": d3CurveBasisOpen,
512
                        "bundle": d3CurveBundle,
513
                        "cardinal": d3CurveCardinal,
514
                        "cardinal-closed": d3CurveCardinalClosed,
515
                        "cardinal-open": d3CurveCardinalOpen,
516
                        "catmull-rom": d3CurveCatmullRom,
517
                        "catmull-rom-closed": d3CurveCatmullRomClosed,
518
                        "catmull-rom-open": d3CurveCatmullRomOpen,
519
                        "monotone-x": d3CurveMonotoneX,
520
                        "monotone-y": d3CurveMonotoneY,
521
                        "natural": d3CurveNatural,
522
                        "linear-closed": d3CurveLinearClosed,
523
                        "linear": d3CurveLinear,
524
                        "step": d3CurveStep,
525
                        "step-after": d3CurveStepAfter,
526
                        "step-before": d3CurveStepBefore
527
                }[interpolation];
528
        },
529

530
        getInterpolateType(d) {
531
                const $$ = this;
9,957✔
532
                const {config} = $$;
9,957✔
533
                const type = config.spline_interpolation_type;
9,957✔
534
                const interpolation = $$.isInterpolationType(type) ? type : "cardinal";
9,957✔
535

536
                return $$.isSplineType(d) ?
9,957✔
537
                        interpolation : (
9,957✔
538
                                $$.isStepType(d) ?
539
                                        config.line_step_type : "linear"
9,312✔
540
                        );
541
        },
542

543
        isWithinBar(that): boolean {
544
                const mouse = getPointer(this.state.event, that);
273✔
545
                const list = getRectSegList(that);
273✔
546
                const [seg0, seg1] = list;
273✔
547
                const x = Math.min(seg0.x, seg1.x);
273✔
548
                const y = Math.min(seg0.y, seg1.y);
273✔
549
                const offset = this.config.bar_sensitivity;
273✔
550
                const {width, height} = that.getBBox();
273✔
551
                const sx = x - offset;
273✔
552
                const ex = x + width + offset;
553
                const sy = y + height + offset;
554
                const ey = y - offset;
555

556
                const isWithin = sx < mouse[0] &&
557
                        mouse[0] < ex &&
558
                        ey < mouse[1] &&
559
                        mouse[1] < sy;
560

561
                return isWithin;
273✔
562
        }
563
};
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

© 2026 Coveralls, Inc