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

IgniteUI / igniteui-angular / 28358286454

29 Jun 2026 08:17AM UTC coverage: 90.127% (-0.05%) from 90.174%
28358286454

Pull #17246

github

web-flow
Merge 21687d939 into 07bdcd752
Pull Request #17246: fix(pivot-grid): fix date format based on the localization

14921 of 17392 branches covered (85.79%)

Branch coverage included in aggregate %.

29 of 32 new or added lines in 4 files covered. (90.63%)

735 existing lines in 76 files now uncovered.

29992 of 32441 relevant lines covered (92.45%)

34632.46 hits per line

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

79.58
/projects/igniteui-angular/core/src/core/utils.ts
1
import { isPlatformBrowser } from '@angular/common';
2
import { Injectable, InjectionToken, PLATFORM_ID, inject } from '@angular/core';
3
import { mergeWith } from 'lodash-es';
4
import { NEVER, Observable } from 'rxjs';
5
import { isDevMode } from '@angular/core';
6
import type { IgxTheme } from '../services/theme/theme.token';
7

8
/** @hidden @internal */
9
export const ELEMENTS_TOKEN = /*@__PURE__*/new InjectionToken<boolean>('elements environment');
3✔
10

11

12
/**
13
 * Returns true if the element's direction is left-to-right
14
 *
15
 * @hidden @internal
16
 */
17
export function isLeftToRight(element: Element): boolean {
18
    return element?.matches(':dir(ltr)') ?? true;
1,218!
19
}
20

21
/**
22
 * @hidden
23
 */
24
export const showMessage = (message: string, isMessageShown: boolean): boolean => {
3✔
25
    if (!isMessageShown && isDevMode()) {
×
26
        console.warn(message);
×
27
    }
28

29
    return true;
×
30
};
31

32
/**
33
 *
34
 * @hidden @internal
35
 */
36
export const getResizeObserver = () => globalThis.window?.ResizeObserver;
78,860✔
37

38
/**
39
 * @hidden
40
 */
41
export function cloneArray<T>(array: T[], deep = false): T[] {
36,172✔
42
    return deep ? (array ?? []).map(cloneValue) : (array ?? []).slice();
41,240✔
43
}
44

45
/**
46
 * @hidden
47
 */
48
export function areEqualArrays<T>(arr1: T[], arr2: T[]): boolean {
49
    if (arr1.length !== arr2.length) return false;
42,708✔
50
        for (let i = 0; i < arr1.length; i++) {
42,647✔
51
          if (arr1[i] !== arr2[i]) return false;
364✔
52
        }
53
        return true;
42,646✔
54
}
55

56
/**
57
 * Doesn't clone leaf items
58
 *
59
 * @hidden
60
 */
61
export const cloneHierarchicalArray = (array: any[], childDataKey: any): any[] => {
3✔
62
    const result: any[] = [];
542✔
63
    if (!array) {
542!
64
        return result;
×
65
    }
66

67
    for (const item of array) {
542✔
68
        const clonedItem = cloneValue(item);
908✔
69
        if (Array.isArray(item[childDataKey])) {
908✔
70
            clonedItem[childDataKey] = cloneHierarchicalArray(clonedItem[childDataKey], childDataKey);
469✔
71
        }
72
        result.push(clonedItem);
908✔
73
    }
74
    return result;
542✔
75
};
76

77
/**
78
 * Creates an object with prototype from provided source and copies
79
 * all properties descriptors from provided source
80
 * @param obj Source to copy prototype and descriptors from
81
 * @returns New object with cloned prototype and property descriptors
82
 */
83
export const copyDescriptors = (obj) => {
3✔
84
    if (obj) {
191✔
85
        return Object.create(
191✔
86
            Object.getPrototypeOf(obj),
87
            Object.getOwnPropertyDescriptors(obj)
88
        );
89
    }
90
}
91

92

93
/**
94
 * Deep clones all first level keys of Obj2 and merges them to Obj1
95
 *
96
 * @param obj1 Object to merge into
97
 * @param obj2 Object to merge from
98
 * @returns Obj1 with merged cloned keys from Obj2
99
 * @hidden
100
 */
101
export const mergeObjects = (obj1: any, obj2: any): any => mergeWith(obj1, obj2, (objValue, srcValue) => {
48,492✔
102
    if (Array.isArray(srcValue)) {
13,661✔
103
        objValue = srcValue;
93✔
104
        return objValue;
93✔
105
    }
106
});
107

108
/**
109
 * Creates deep clone of provided value.
110
 * Supports primitive values, dates and objects.
111
 * If passed value is array returns shallow copy of the array.
112
 *
113
 * @param value value to clone
114
 * @returns Deep copy of provided value
115
 * @hidden
116
 */
117
export const cloneValue = (value: any): any => {
3✔
118
    if (isDate(value)) {
1,021,826✔
119
        return new Date(value.getTime());
21,553✔
120
    }
121
    if (Array.isArray(value)) {
1,000,273✔
122
        return value.slice();
42,297✔
123
    }
124

125
    if (value instanceof Map || value instanceof Set) {
957,976✔
126
        return value;
83,385✔
127
    }
128

129
    if (isObject(value)) {
874,591✔
130
        const result = {};
164,485✔
131

132
        for (const key of Object.keys(value)) {
164,485✔
133
            if (key === "externalObject") {
909,273!
UNCOV
134
                continue;
×
135
            }
136
            result[key] = cloneValue(value[key]);
909,273✔
137
        }
138
        return result;
164,485✔
139
    }
140
    return value;
710,106✔
141
};
142

143
/**
144
 * Creates deep clone of provided value.
145
 * Supports primitive values, dates and objects.
146
 * If passed value is array returns shallow copy of the array.
147
 * For Objects property values and references are cached and reused.
148
 * This allows for circular references to same objects.
149
 *
150
 * @param value value to clone
151
 * @param cache map of cached values already parsed
152
 * @returns Deep copy of provided value
153
 * @hidden
154
 */
155
export const cloneValueCached = (value: any, cache: Map<any, any>): any => {
3✔
156
    if (isDate(value)) {
×
UNCOV
157
        return new Date(value.getTime());
×
158
    }
159
    if (Array.isArray(value)) {
×
UNCOV
160
        return [...value];
×
161
    }
162

163
    if (value instanceof Map || value instanceof Set) {
×
UNCOV
164
        return value;
×
165
    }
166

167
    if (isObject(value)) {
×
168
        if (cache.has(value)) {
×
UNCOV
169
            return cache.get(value);
×
170
        }
171

172
        const result = {};
×
UNCOV
173
        cache.set(value, result);
×
174

175
        for (const key of Object.keys(value)) {
×
UNCOV
176
            result[key] = cloneValueCached(value[key], cache);
×
177
        }
UNCOV
178
        return result;
×
179
    }
UNCOV
180
    return value;
×
181
};
182

183
/**
184
 * Parse provided input to Date.
185
 *
186
 * @param value input to parse
187
 * @returns Date if parse succeed or null
188
 * @hidden
189
 */
190
export const parseDate = (value: any): Date | null => {
3✔
191
    // if value is Invalid Date return null
192
    if (isDate(value)) {
3,218✔
193
        return !isNaN(value.getTime()) ? value : null;
1,491!
194
    }
195
    return value ? new Date(value) : null;
1,727✔
196
};
197

198
/**
199
 * Returns an array with unique dates only.
200
 *
201
 * @param columnValues collection of date values (might be numbers or ISO 8601 strings)
202
 * @returns collection of unique dates.
203
 * @hidden
204
 */
205
export const uniqueDates = (columnValues: any[]) => columnValues.reduce((a, c) => {
3✔
206
    if (!a.cache[c.label]) {
×
UNCOV
207
        a.result.push(c);
×
208
    }
209
    a.cache[c.label] = true;
×
UNCOV
210
    return a;
×
211
}, { result: [], cache: {} }).result;
212

213
/**
214
 * Checks if provided variable is Object
215
 *
216
 * @param value Value to check
217
 * @returns true if provided variable is Object
218
 * @hidden
219
 */
220
export const isObject = (value: any): boolean => !!(value && value.toString() === '[object Object]');
881,949✔
221

222
/**
223
 * Checks if provided variable is Date
224
 *
225
 * @param value Value to check
226
 * @returns true if provided variable is Date
227
 * @hidden
228
 */
229
export const isDate = (value: any): value is Date => {
3✔
230
    return Object.prototype.toString.call(value) === "[object Date]";
3,779,288✔
231
}
232

233
/**
234
 * Checks if the two passed arguments are equal
235
 * Currently supports date objects
236
 *
237
 * @param obj1
238
 * @param obj2
239
 * @returns: `boolean`
240
 * @hidden
241
 */
242
export const isEqual = (obj1, obj2): boolean => {
3✔
243
    if (isDate(obj1) && isDate(obj2)) {
862✔
244
        return obj1.getTime() === obj2.getTime();
213✔
245
    }
246
    return obj1 === obj2;
649✔
247
};
248

249
/**
250
 * Limits a number to a range between a minimum and a maximum value.
251
 *
252
 * @param number
253
 * @param min
254
 * @param max
255
 * @returns: `number`
256
 * @hidden
257
 */
258
export const clamp = (number: number, min: number, max: number) =>
3✔
259
    Math.max(min, Math.min(number, max));
2✔
260

261

262
/**
263
 * Utility service taking care of various utility functions such as
264
 * detecting browser features, general cross browser DOM manipulation, etc.
265
 *
266
 * @hidden @internal
267
 */
268
@Injectable({ providedIn: 'root' })
269
export class PlatformUtil {
3✔
270
    private platformId = inject(PLATFORM_ID);
4,807✔
271

272
    public isBrowser: boolean = isPlatformBrowser(this.platformId);
4,807✔
273
    public isIOS = this.isBrowser && /iPad|iPhone|iPod/.test(navigator.userAgent) && !('MSStream' in window);
4,807!
274
    public isSafari = this.isBrowser && /Safari[\/\s](\d+\.\d+)/.test(navigator.userAgent);
4,807✔
275
    public isFirefox = this.isBrowser && /Firefox[\/\s](\d+\.\d+)/.test(navigator.userAgent);
4,807✔
276
    public isEdge = this.isBrowser && /Edge[\/\s](\d+\.\d+)/.test(navigator.userAgent);
4,807✔
277
    public isChromium = this.isBrowser && (/Chrom|e?ium/g.test(navigator.userAgent) ||
4,807!
278
        /Google Inc/g.test(navigator.vendor)) && !/Edge/g.test(navigator.userAgent);
279
    public browserVersion = this.isBrowser ? parseFloat(navigator.userAgent.match(/Version\/([\d.]+)/)?.at(1)) : 0;
4,807!
280

281
    /** @hidden @internal */
282
    public isElements = inject(ELEMENTS_TOKEN, { optional: true });
4,807✔
283

284
    public KEYMAP = {
4,807✔
285
        ENTER: 'Enter',
286
        SPACE: ' ',
287
        ESCAPE: 'Escape',
288
        ARROW_DOWN: 'ArrowDown',
289
        ARROW_UP: 'ArrowUp',
290
        ARROW_LEFT: 'ArrowLeft',
291
        ARROW_RIGHT: 'ArrowRight',
292
        END: 'End',
293
        HOME: 'Home',
294
        PAGE_DOWN: 'PageDown',
295
        PAGE_UP: 'PageUp',
296
        F2: 'F2',
297
        TAB: 'Tab',
298
        SEMICOLON: ';',
299
        // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values#editing_keys
300
        DELETE: 'Delete',
301
        BACKSPACE: 'Backspace',
302
        CONTROL: 'Control',
303
        X: 'x',
304
        Y: 'y',
305
        Z: 'z'
306
    } as const;
307

308
    /**
309
     * @hidden @internal
310
     * Returns the actual size of the node content, using Range
311
     * ```typescript
312
     * let range = document.createRange();
313
     * let column = this.grid.columnList.filter(c => c.field === 'ID')[0];
314
     *
315
     * let size = getNodeSizeViaRange(range, column.cells[0].nativeElement);
316
     *
317
     * @remarks
318
     * The last parameter is useful when the size of the element to measure is modified by a
319
     * parent element that has explicit size. In such cases the calculated size is never lower
320
     * and the function may instead remove the parent size while measuring to get the correct value.
321
     * ```
322
     */
323
    public getNodeSizeViaRange(range: Range, node: HTMLElement, sizeHoldingNode?: HTMLElement) {
324
        let overflow = null;
309✔
325
        let nodeStyles: string[];
326

327
        if (!this.isFirefox) {
309✔
328
            overflow = node.style.overflow;
309✔
329
            // we need that hack - otherwise content won't be measured correctly in IE/Edge
330
            node.style.overflow = 'visible';
309✔
331
        }
332

333
        if (sizeHoldingNode) {
309✔
334
            const style = sizeHoldingNode.style;
39✔
335
            nodeStyles = [style.width, style.minWidth, style.flexBasis];
39✔
336
            style.width = '';
39✔
337
            style.minWidth = '';
39✔
338
            style.flexBasis = '';
39✔
339
        }
340

341
        range.selectNodeContents(node);
309✔
342
        const scale = node.getBoundingClientRect().width / node.offsetWidth;
309✔
343
        const width = range.getBoundingClientRect().width / scale;
309✔
344

345
        if (!this.isFirefox) {
309✔
346
            // we need that hack - otherwise content won't be measured correctly in IE/Edge
347
            node.style.overflow = overflow;
309✔
348
        }
349

350
        if (sizeHoldingNode) {
309✔
351
            sizeHoldingNode.style.width = nodeStyles[0];
39✔
352
            sizeHoldingNode.style.minWidth = nodeStyles[1];
39✔
353
            sizeHoldingNode.style.flexBasis = nodeStyles[2];
39✔
354
        }
355

356
        return width;
309✔
357
    }
358

359

360
    /**
361
     * Returns true if the current keyboard event is an activation key (Enter/Space bar)
362
     *
363
     * @hidden
364
     * @internal
365
     *
366
     * @memberof PlatformUtil
367
     */
368
    public isActivationKey(event: KeyboardEvent) {
369
        return event.key === this.KEYMAP.ENTER || event.key === this.KEYMAP.SPACE;
58✔
370
    }
371

372
    /**
373
     * Returns true if the current keyboard event is a combination that closes the filtering UI of the grid. (Escape/Ctrl+Shift+L)
374
     *
375
     * @hidden
376
     * @internal
377
     * @param event
378
     * @memberof PlatformUtil
379
     */
380
    public isFilteringKeyCombo(event: KeyboardEvent) {
381
        return event.key === this.KEYMAP.ESCAPE || (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'l');
46✔
382
    }
383

384
    /**
385
     * @hidden @internal
386
     */
387
    public isLeftClick(event: PointerEvent | MouseEvent) {
388
        return event.button === 0;
831✔
389
    }
390

391
    /**
392
     * @hidden @internal
393
     */
394
    public isNavigationKey(key: string) {
395
        return [
10✔
396
            this.KEYMAP.HOME, this.KEYMAP.END, this.KEYMAP.SPACE,
397
            this.KEYMAP.ARROW_DOWN, this.KEYMAP.ARROW_LEFT, this.KEYMAP.ARROW_RIGHT, this.KEYMAP.ARROW_UP
398
        ].includes(key as any);
399
    }
400
}
401

402
/**
403
 * @hidden
404
 */
405
export const flatten = (arr: any[]) => {
3✔
406
    let result = [];
502,386✔
407

408
    arr.forEach(el => {
502,386✔
409
        result.push(el);
980,238✔
410
        if (el.children) {
980,238✔
411
            const children = Array.isArray(el.children) ? el.children : el.children.toArray();
221,509!
412
            result = result.concat(flatten(children));
221,509✔
413
        }
414
    });
415
    return result;
502,386✔
416
};
417

418
export interface CancelableEventArgs {
419
    /**
420
     * Provides the ability to cancel the event.
421
     */
422
    cancel: boolean;
423
}
424

425
export interface IBaseEventArgs {
426
    /**
427
     * Provides reference to the owner component.
428
     */
429
    owner?: any;
430
}
431

432
export interface CancelableBrowserEventArgs extends CancelableEventArgs {
433
    /* blazorSuppress */
434
    /** Browser event */
435
    event?: Event;
436
}
437

438
export interface IBaseCancelableBrowserEventArgs extends CancelableBrowserEventArgs, IBaseEventArgs { }
439

440
export interface IBaseCancelableEventArgs extends CancelableEventArgs, IBaseEventArgs { }
441

442
export const NAVIGATION_KEYS = new Set([
3✔
443
    'down',
444
    'up',
445
    'left',
446
    'right',
447
    'arrowdown',
448
    'arrowup',
449
    'arrowleft',
450
    'arrowright',
451
    'home',
452
    'end',
453
    'space',
454
    'spacebar',
455
    ' '
456
]);
457

458
/**
459
 * @hidden
460
 * @internal
461
 *
462
 * Creates a new ResizeObserver on `target` and returns it as an Observable.
463
 * Run the resizeObservable outside angular zone, because it patches the MutationObserver which causes an infinite loop.
464
 * Related issue: https://github.com/angular/angular/issues/31712
465
 */
466
export const resizeObservable = (target: HTMLElement): Observable<ResizeObserverEntry[]> => {
3✔
467
    const resizeObserver = getResizeObserver();
16,534✔
468
    // check whether we are on server env or client env
469
    if (resizeObserver) {
16,534✔
470
        return new Observable((observer) => {
16,534✔
471
            const instance = new resizeObserver((entries: ResizeObserverEntry[]) => {
16,534✔
472
                observer.next(entries);
6,796✔
473
            });
474
            instance.observe(target);
16,534✔
475
            const unsubscribe = () => instance.disconnect();
16,534✔
476
            return unsubscribe;
16,534✔
477
        });
478
    }
479
    // if on a server env return a empty observable that does not complete immediately
UNCOV
480
    return NEVER;
×
481

482
}
483

484
/**
485
 * @hidden
486
 * @internal
487
 *
488
 * Compares two maps.
489
 */
490
export const compareMaps = (map1: Map<any, any>, map2: Map<any, any>): boolean => {
3✔
491
    if (!map2) {
471✔
492
        return !map1;
3✔
493
    }
494
    if (map1.size !== map2.size) {
468!
UNCOV
495
        return false;
×
496
    }
497
    let match = true;
468✔
498
    const keys = Array.from(map2.keys());
468✔
499
    for (const key of keys) {
468✔
500
        if (map1.has(key)) {
468!
501
            match = map1.get(key) === map2.get(key);
468✔
502
        } else {
UNCOV
503
            match = false;
×
504
        }
505
        if (!match) {
468✔
506
            break;
12✔
507
        }
508
    }
509
    return match;
468✔
510
};
511

512
function _isObject(entity: unknown): entity is object {
513
    return entity != null && typeof entity === 'object';
4,882,624✔
514
}
515

516
export function columnFieldPath(path?: string): string[] {
517
    return path?.split('.') ?? [];
2,386,955✔
518
}
519

520
/**
521
 * Given a property access path in the format `x.y.z` resolves and returns
522
 * the value of the `z` property in the passed object.
523
 *
524
 * @hidden
525
 * @internal
526
 */
527
export function resolveNestedPath<T extends object, U>(obj: unknown, pathParts: string[], defaultValue?: U): T | U | undefined {
528
    if (!_isObject(obj) || pathParts.length < 1) {
2,440,986✔
529
        return defaultValue;
77✔
530
    }
531

532
    let current = obj;
2,440,909✔
533

534
    for (const key of pathParts) {
2,440,909✔
535
        if (_isObject(current) && key in (current as T)) {
2,441,638✔
536
            current = current[key];
2,174,249✔
537
        } else {
538
            return defaultValue;
267,389✔
539
        }
540
    }
541

542
    return current as T;
2,173,520✔
543
}
544

545
/**
546
 *
547
 * Given a property access path in the format `x.y.z` and a value
548
 * this functions builds and returns an object following the access path.
549
 *
550
 * @example
551
 * ```typescript
552
 * console.log('x.y.z.', 42);
553
 * >> { x: { y: { z: 42 } } }
554
 * ```
555
 *
556
 * @hidden
557
 * @internal
558
 */
559
export const reverseMapper = (path: string, value: any) => {
3✔
560
    const obj = {};
197✔
561
    const parts = path?.split('.') ?? [];
197!
562

563
    let _prop = parts.shift();
197✔
564
    let mapping: any;
565

566
    // Initial binding for first level bindings
567
    obj[_prop] = value;
197✔
568
    mapping = obj;
197✔
569

570
    parts.forEach(prop => {
197✔
571
        // Start building the hierarchy
572
        mapping[_prop] = {};
14✔
573
        // Go down a level
574
        mapping = mapping[_prop];
14✔
575
        // Bind the value and move the key
576
        mapping[prop] = value;
14✔
577
        _prop = prop;
14✔
578
    });
579

580
    return obj;
197✔
581
};
582

583
export const isConstructor = (ref: any) => typeof ref === 'function' && Boolean(ref.prototype) && Boolean(ref.prototype.constructor);
26,684✔
584

585
/** Converts pixel values to their rem counterparts for a base value */
586
export const rem = (value: number | string) => {
3✔
587
    const base = parseFloat(globalThis.window?.getComputedStyle(globalThis.document?.documentElement).getPropertyValue('--ig-base-font-size'))
122,627✔
588
    return Number(value) / base;
122,627✔
589
}
590

591
/** Get the size of the component as derived from the CSS size variable */
592
export function getComponentSize(el: Element) {
593
    return globalThis.window?.getComputedStyle(el).getPropertyValue('--component-size');
82✔
594
}
595

596
/** Get the first item in an array */
597
export function first<T>(arr: T[]) {
598
    return arr.at(0) as T;
212,335✔
599
}
600

601
/** Get the last item in an array */
602
export function last<T>(arr: T[]) {
603
    return arr.at(-1) as T;
206,863✔
604
}
605

606
/** Calculates the modulo of two numbers, ensuring a non-negative result. */
607
export function modulo(n: number, d: number) {
608
    return ((n % d) + d) % d;
5,013✔
609
}
610

611
/**
612
 * Splits an array into chunks of length `size` and returns a generator
613
 * yielding each chunk.
614
 * The last chunk may contain less than `size` elements.
615
 *
616
 * @example
617
 * ```typescript
618
 * const arr = [0,1,2,3,4,5,6,7,8,9];
619
 *
620
 * Array.from(chunk(arr, 2)) // [[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]
621
 * Array.from(chunk(arr, 3)) // [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]
622
 * Array.from(chunk([], 3)) // []
623
 * Array.from(chunk(arr, -3)) // Error
624
 * ```
625
 */
626
export function* intoChunks<T>(arr: T[], size: number) {
627
    if (size < 1) {
5,012!
UNCOV
628
        throw new Error('size must be an integer >= 1');
×
629
    }
630
    for (let i = 0; i < arr.length; i += size) {
5,012✔
631
        yield arr.slice(i, i + size);
30,072✔
632
    }
633
}
634

635
/**
636
 * @param size
637
 * @returns string that represents the --component-size default value
638
 */
639
export function getComponentCssSizeVar(size: string) {
UNCOV
640
    switch (size) {
×
641
        case "1":
UNCOV
642
            return 'var(--ig-size, var(--ig-size-small))';
×
643
        case "2":
UNCOV
644
            return 'var(--ig-size, var(--ig-size-medium))';
×
645
        default:
UNCOV
646
            return 'var(--ig-size, var(--ig-size-large))';
×
647
    }
648
}
649

650
/**
651
 * @param path - The URI path to be normalized.
652
 * @returns string encoded using the encodeURI function.
653
 */
654
export function normalizeURI(path: string) {
655
    return path?.split('/').map(encodeURI).join('/');
8✔
656
}
657

658
export function getComponentTheme(el: Element) {
659
    return globalThis.window
52,244✔
660
        ?.getComputedStyle(el)
661
        .getPropertyValue('--theme')
662
        .trim() as IgxTheme;
663
}
664

665
/**
666
 * Collection re-created w/ the built in track by identity will always log
667
 * warning even for valid cases of recalculating all collection items.
668
 * See https://github.com/angular/angular/blob/55581b4181639568fb496e91055142a1b489e988/packages/core/src/render3/instructions/control_flow.ts#L393-L409
669
 * Current solution explicit track function doing the same as suggested in:
670
 * https://github.com/angular/angular/issues/56471#issuecomment-2180315803
671
 * This should be used with moderation and when necessary.
672
 * @internal
673
 */
674
export function trackByIdentity<T>(item: T): T {
675
    return item;
137,533✔
676
}
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