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

evolvedbinary / elemental / 982

29 Apr 2025 08:34PM UTC coverage: 56.409% (+0.007%) from 56.402%
982

push

circleci

adamretter
[feature] Improve README.md badges

28451 of 55847 branches covered (50.94%)

Branch coverage included in aggregate %.

77468 of 131924 relevant lines covered (58.72%)

0.59 hits per line

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

79.0
/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDeepEqual.java
1
/*
2
 * Elemental
3
 * Copyright (C) 2024, Evolved Binary Ltd
4
 *
5
 * admin@evolvedbinary.com
6
 * https://www.evolvedbinary.com | https://www.elemental.xyz
7
 *
8
 * Use of this software is governed by the Business Source License 1.1
9
 * included in the LICENSE file and at www.mariadb.com/bsl11.
10
 *
11
 * Change Date: 2028-04-27
12
 *
13
 * On the date above, in accordance with the Business Source License, use
14
 * of this software will be governed by the Apache License, Version 2.0.
15
 *
16
 * Additional Use Grant: Production use of the Licensed Work for a permitted
17
 * purpose. A Permitted Purpose is any purpose other than a Competing Use.
18
 * A Competing Use means making the Software available to others in a commercial
19
 * product or service that: substitutes for the Software; substitutes for any
20
 * other product or service we offer using the Software that exists as of the
21
 * date we make the Software available; or offers the same or substantially
22
 * similar functionality as the Software.
23
 *
24
 * NOTE: Parts of this file contain code from 'The eXist-db Authors'.
25
 *       The original license header is included below.
26
 *
27
 * =====================================================================
28
 *
29
 * eXist-db Open Source Native XML Database
30
 * Copyright (C) 2001 The eXist-db Authors
31
 *
32
 * info@exist-db.org
33
 * http://www.exist-db.org
34
 *
35
 * This library is free software; you can redistribute it and/or
36
 * modify it under the terms of the GNU Lesser General Public
37
 * License as published by the Free Software Foundation; either
38
 * version 2.1 of the License, or (at your option) any later version.
39
 *
40
 * This library is distributed in the hope that it will be useful,
41
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
42
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
43
 * Lesser General Public License for more details.
44
 *
45
 * You should have received a copy of the GNU Lesser General Public
46
 * License along with this library; if not, write to the Free Software
47
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
48
 */
49
package org.exist.xquery.functions.fn;
50

51
import com.ibm.icu.text.Collator;
52
import io.lacuna.bifurcan.IEntry;
53
import org.apache.logging.log4j.LogManager;
54
import org.apache.logging.log4j.Logger;
55
import org.exist.Namespaces;
56
import org.exist.dom.persistent.NodeProxy;
57
import org.exist.dom.QName;
58
import org.exist.xquery.Cardinality;
59
import org.exist.xquery.Constants;
60
import org.exist.xquery.Dependency;
61
import org.exist.xquery.Function;
62
import org.exist.xquery.FunctionSignature;
63
import org.exist.xquery.Profiler;
64
import org.exist.xquery.ValueComparison;
65
import org.exist.xquery.XPathException;
66
import org.exist.xquery.XQueryContext;
67
import org.exist.xquery.functions.array.ArrayType;
68
import org.exist.xquery.functions.map.AbstractMapType;
69
import org.exist.xquery.value.AtomicValue;
70
import org.exist.xquery.value.BooleanValue;
71
import org.exist.xquery.value.FunctionReturnSequenceType;
72
import org.exist.xquery.value.FunctionParameterSequenceType;
73
import org.exist.xquery.value.Item;
74
import org.exist.xquery.value.NodeValue;
75
import org.exist.xquery.value.NumericValue;
76
import org.exist.xquery.value.Sequence;
77
import org.exist.xquery.value.SequenceType;
78
import org.exist.xquery.value.Type;
79
import org.w3c.dom.NamedNodeMap;
80
import org.w3c.dom.Node;
81

82
import javax.annotation.Nullable;
83

84
/**
85
 * Implements the fn:deep-equal library function.
86
 *
87
 * @author <a href="mailto:adam@evolvedbinary.com">Adam Retter</a>
88
 */
89
public class FunDeepEqual extends CollatingFunction {
90

91
    protected static final Logger logger = LogManager.getLogger(FunDeepEqual.class);
1✔
92

93
    public final static FunctionSignature[] signatures = {
1✔
94
        new FunctionSignature(
1✔
95
            new QName("deep-equal", Function.BUILTIN_FUNCTION_NS),
1✔
96
            "Returns true() iff every item in $items-1 is deep-equal to the item " +
1✔
97
            "at the same position in $items-2, false() otherwise. " +
98
            "If both $items-1 and $items-2 are the empty sequence, returns true(). ",
99
            new SequenceType[] {
1✔
100
                new FunctionParameterSequenceType("items-1", Type.ITEM,
1✔
101
                    Cardinality.ZERO_OR_MORE, "The first item sequence"),
1✔
102
                new FunctionParameterSequenceType("items-2", Type.ITEM,
1✔
103
                    Cardinality.ZERO_OR_MORE, "The second item sequence")
1✔
104
            },
105
            new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
1✔
106
                "true() if the sequences are deep-equal, false() otherwise")
1✔
107
            ),
108
        new FunctionSignature(
1✔
109
            new QName("deep-equal", Function.BUILTIN_FUNCTION_NS),
1✔
110
            "Returns true() iff every item in $items-1 is deep-equal to the item " +
1✔
111
            "at the same position in $items-2, false() otherwise. " +
112
            "If both $items-1 and $items-2 are the empty sequence, returns true(). " +
113
            "Comparison collation is specified by $collation-uri. " + 
114
            THIRD_REL_COLLATION_ARG_EXAMPLE,
115
            new SequenceType[] {
1✔
116
                new FunctionParameterSequenceType("items-1", Type.ITEM,
1✔
117
                    Cardinality.ZERO_OR_MORE, "The first item sequence"),
1✔
118
                new FunctionParameterSequenceType("items-2", Type.ITEM,
1✔
119
                    Cardinality.ZERO_OR_MORE, "The second item sequence"),
1✔
120
                new FunctionParameterSequenceType("collation-uri", Type.STRING,
1✔
121
                    Cardinality.EXACTLY_ONE, "The collation URI")
1✔
122
            },
123
            new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
1✔
124
                "true() if the sequences are deep-equal, false() otherwise")
1✔
125
        )
126
    };
1✔
127

128
    public FunDeepEqual(XQueryContext context, FunctionSignature signature) {
129
        super(context, signature);
1✔
130
    }
1✔
131

132
    public int getDependencies() {
133
        return Dependency.CONTEXT_SET | Dependency.CONTEXT_ITEM;
1✔
134
    }
135

136
    public Sequence eval(Sequence contextSequence, Item contextItem)
137
            throws XPathException {
138
        if (context.getProfiler().isEnabled()) {
1!
139
            context.getProfiler().start(this);
×
140
            context.getProfiler().message(this, Profiler.DEPENDENCIES,
×
141
                "DEPENDENCIES", Dependency.getDependenciesName(this.getDependencies()));
×
142
            if (contextSequence != null)
×
143
                {context.getProfiler().message(this, Profiler.START_SEQUENCES,
×
144
                    "CONTEXT SEQUENCE", contextSequence);}
×
145
            if (contextItem != null)
×
146
                {context.getProfiler().message(this, Profiler.START_SEQUENCES,
×
147
                    "CONTEXT ITEM", contextItem.toSequence());}
×
148
        }
149
        final Sequence[] args = getArguments(contextSequence, contextItem);
1✔
150
        final Collator collator = getCollator(contextSequence, contextItem, 3);
1✔
151
        final Sequence result = BooleanValue.valueOf(deepEqualsSeq(args[0], args[1], collator));
1✔
152
        if (context.getProfiler().isEnabled()) 
1!
153
            {context.getProfiler().end(this, "", result);} 
×
154
        return result;
1✔
155
    }
156

157
    /**
158
     * Deep comparison of two Sequences according to the rules of fn:deep-equals.
159
     *
160
     * @param sequence1 the first Sequence to be compared.
161
     * @param sequence2 the second Sequence to be compared.
162
     * @param collator a collator to use for the comparison, or null to use the default collator.
163
     *
164
     * @return a negative integer, zero, or a positive integer, if the first argument is less than, equal to, or greater than the second.
165
     */
166
    public static int deepCompareSeq(final Sequence sequence1, final Sequence sequence2, @Nullable final Collator collator) {
167
        if (sequence1 == sequence2) {
1✔
168
            return Constants.EQUAL;
1✔
169
        }
170

171
        final int sequence1Count = sequence1.getItemCount();
1✔
172
        final int sequence2Count = sequence2.getItemCount();
1✔
173
        if (sequence1Count == sequence2Count) {
1✔
174
            for (int i = 0; i < sequence1Count; i++) {
1✔
175
                final Item item1 = sequence1.itemAt(i);
1✔
176
                final Item item2 = sequence2.itemAt(i);
1✔
177

178
                final int comparison = deepCompare(item1, item2, collator);
1✔
179
                if (comparison != Constants.EQUAL) {
1✔
180
                    return comparison;
1✔
181
                }
182
            }
183
            return Constants.EQUAL;
1✔
184
        } else {
185
            return sequence1Count < sequence2Count ? Constants.INFERIOR : Constants.SUPERIOR;
1✔
186
        }
187
    }
188

189
    /**
190
     * Deep comparison of two Items according to the rules of fn:deep-equals.
191
     *
192
     * @param item1 the first Item to be compared.
193
     * @param item2 the second Item to be compared.
194
     * @param collator a collator to use for the comparison, or null to use the default collator.
195
     *
196
     * @return a negative integer, zero, or a positive integer, if the first argument is less than, equal to, or greater than the second.
197
     *
198
     * @throws UnexpectedItemTypeException if an item has an unknown type.
199
     */
200
    public static int deepCompare(final Item item1, final Item item2, @Nullable final Collator collator) {
201
        if (item1 == item2) {
1✔
202
            return Constants.EQUAL;
1✔
203
        }
204

205
        try {
206
            if (item1.getType() == Type.ARRAY_ITEM || item2.getType() == Type.ARRAY_ITEM) {
1!
207
                if (item1.getType() != item2.getType()) {
1!
208
                    return Constants.INFERIOR;
×
209
                }
210
                final ArrayType array1 = (ArrayType) item1;
1✔
211
                final ArrayType array2 = (ArrayType) item2;
1✔
212
                final int array1Size = array1.getSize();
1✔
213
                final int array2Size = array2.getSize();
1✔
214
                if (array1Size == array2Size) {
1!
215
                    for (int i = 0; i < array1.getSize(); i++) {
1✔
216
                        final int comparison = deepCompareSeq(array1.get(i), array2.get(i), collator);
1✔
217
                        if (comparison != Constants.EQUAL) {
1✔
218
                            return comparison;
1✔
219
                        }
220
                    }
221
                    return Constants.EQUAL;
1✔
222
                } else {
223
                    return array1Size < array2Size ? Constants.INFERIOR : Constants.SUPERIOR;
×
224
                }
225
            }
226

227
            if (item1.getType() == Type.MAP_ITEM || item2.getType() == Type.MAP_ITEM) {
1!
228
                if (item1.getType() != item2.getType()) {
1!
229
                    return Constants.INFERIOR;
×
230
                }
231
                final AbstractMapType map1 = (AbstractMapType) item1;
1✔
232
                final AbstractMapType map2 = (AbstractMapType) item2;
1✔
233
                final int map1Size = map1.size();
1✔
234
                final int map2Size = map2.size();
1✔
235

236
                if (map1Size == map2Size) {
1!
237
                    for (final IEntry<AtomicValue, Sequence> entry1 : map1) {
1✔
238
                        if (!map2.contains(entry1.key())) {
1!
239
                            return Constants.SUPERIOR;
×
240
                        }
241

242
                        final int comparison = deepCompareSeq(entry1.value(), map2.get(entry1.key()), collator);
1✔
243
                        if (comparison != Constants.EQUAL) {
1!
244
                            return comparison;
×
245
                        }
246
                    }
247
                    return Constants.EQUAL;
1✔
248
                } else {
249
                    return map1Size < map2Size ? Constants.INFERIOR : Constants.SUPERIOR;
×
250
                }
251
            }
252

253
            final boolean item1IsAtomic = Type.subTypeOf(item1.getType(), Type.ANY_ATOMIC_TYPE);
1✔
254
            final boolean item2IsAtomic = Type.subTypeOf(item2.getType(), Type.ANY_ATOMIC_TYPE);
1✔
255
            if (item1IsAtomic || item2IsAtomic) {
1!
256
                if (!item1IsAtomic) {
1!
257
                    return Constants.SUPERIOR;
×
258
                }
259

260
                if (!item2IsAtomic) {
1!
261
                    return Constants.INFERIOR;
×
262
                }
263

264
                try {
265
                    final AtomicValue av = (AtomicValue) item1;
1✔
266
                    final AtomicValue bv = (AtomicValue) item2;
1✔
267
                    if (Type.subTypeOfUnion(av.getType(), Type.NUMERIC) &&
1✔
268
                            Type.subTypeOfUnion(bv.getType(), Type.NUMERIC)) {
1✔
269
                        //or if both values are NaN
270
                        if (((NumericValue) item1).isNaN() && ((NumericValue) item2).isNaN()) {
1!
271
                            return Constants.EQUAL;
1✔
272
                        }
273
                    }
274

275
                    return ValueComparison.compareAtomic(collator, av, bv);
1✔
276
                } catch (final XPathException e) {
1✔
277
                    if (logger.isTraceEnabled()) {
1!
278
                        logger.trace(e.getMessage());
×
279
                    }
280
                    return Constants.INFERIOR;
1✔
281
                }
282
            }
283

284
            if (item1.getType() != item2.getType()) {
1✔
285
                return Constants.INFERIOR;
1✔
286
            }
287
            final NodeValue nva = (NodeValue) item1;
1✔
288
            final NodeValue nvb = (NodeValue) item2;
1✔
289
            // NOTE(AR): intentional reference equality check
290
            if (nva == nvb) {
1!
291
                return Constants.EQUAL;
×
292
            }
293

294
            try {
295
                //Don't use this shortcut for in-memory nodes
296
                //since the symbol table is ignored.
297
                if (nva.getImplementationType() != NodeValue.IN_MEMORY_NODE &&
1✔
298
                        nva.equals(nvb)) {
1✔
299
                    return Constants.EQUAL;  // shortcut!
1✔
300
                }
301
            } catch (final XPathException e) {
×
302
                // apparently incompatible values, do manual comparison
303
            }
304

305
            final Node node1;
306
            final Node node2;
307
            switch (item1.getType()) {
1!
308
                case Type.DOCUMENT:
309
                    node1 = nva instanceof Node nnva ? nnva : ((NodeProxy) nva).getOwnerDocument();
1!
310
                    node2 = nvb instanceof Node nnvb ? nnvb : ((NodeProxy) nvb).getOwnerDocument();
1!
311
                    return compareContents(node1, node2, collator);
1✔
312

313
                case Type.ELEMENT:
314
                    node1 = nva.getNode();
1✔
315
                    node2 = nvb.getNode();
1✔
316
                    return compareElements(node1, node2, collator);
1✔
317

318
                case Type.ATTRIBUTE:
319
                    node1 = nva.getNode();
1✔
320
                    node2 = nvb.getNode();
1✔
321
                    final int attributeNameComparison = compareNames(node1, node2);
1✔
322
                    if (attributeNameComparison != Constants.EQUAL) {
1✔
323
                        return attributeNameComparison;
1✔
324
                    }
325
                    return safeCompare(node1.getNodeValue(), node2.getNodeValue(), collator);
1✔
326

327
                case Type.PROCESSING_INSTRUCTION:
328
                case Type.NAMESPACE:
329
                    node1 = nva.getNode();
×
330
                    node2 = nvb.getNode();
×
331
                    final int nameComparison = safeCompare(node1.getNodeName(), node2.getNodeName(), null);
×
332
                    if (nameComparison != Constants.EQUAL) {
×
333
                        return nameComparison;
×
334
                    }
335
                    return safeCompare(nva.getStringValue(), nvb.getStringValue(), collator);
×
336

337
                case Type.TEXT:
338
                case Type.COMMENT:
339
                    return safeCompare(nva.getStringValue(), nvb.getStringValue(), collator);
1✔
340

341
                default:
342
                    throw new UnexpectedItemTypeException(item1);
×
343
            }
344
        } catch (final XPathException e) {
×
345
            logger.error(e.getMessage(), e);
×
346
            return Constants.INFERIOR;
×
347
        }
348
    }
349

350
    public static class UnexpectedItemTypeException extends RuntimeException {
351
        public UnexpectedItemTypeException(final Item item) {
352
            super("Unexpected item type: " + Type.getTypeName(item.getType()));
×
353
        }
×
354
    }
355

356

357
    /**
358
     * Deep equality of two Sequences according to the rules of fn:deep-equals.
359
     *
360
     * @param sequence1 the first Sequence to be compared.
361
     * @param sequence2 the second Sequence to be compared.
362
     * @param collator a collator to use for the comparison, or null to use the default collator.
363
     *
364
     * @return true if the Sequences are equal according to the rules of fn:deep-equals, false otherwise.
365
     */
366
    public static boolean deepEqualsSeq(final Sequence sequence1, final Sequence sequence2, @Nullable final Collator collator) {
367
        return deepCompareSeq(sequence1, sequence2, collator) == Constants.EQUAL;
1✔
368
    }
369

370
    /**
371
     * Deep equality of two Items according to the rules of fn:deep-equals.
372
     *
373
     * @param item1 the first Item to be compared.
374
     * @param item2 the second Item to be compared.
375
     * @param collator a collator to use for the comparison, or null to use the default collator.
376
     *
377
     * @return true if the Items are equal according to the rules of fn:deep-equals, false otherwise.
378
     */
379
    public static boolean deepEquals(final Item item1, final Item item2, @Nullable final Collator collator) {
380
        return deepCompare(item1, item2, collator) == Constants.EQUAL;
1✔
381
    }
382

383
    private static int compareElements(final Node a, final Node b, @Nullable final Collator collator) {
384
        int comparison = compareNames(a, b);
1✔
385
        if (comparison != Constants.EQUAL) {
1✔
386
            return comparison;
1✔
387
        }
388

389
        comparison = compareAttributes(a, b, collator);
1✔
390
        if (comparison != Constants.EQUAL) {
1✔
391
            return comparison;
1✔
392
        }
393

394
        return compareContents(a, b, collator);
1✔
395
    }
396

397
    private static int compareContents(Node a, Node b, @Nullable final Collator collator) {
398
        a = findNextTextOrElementNode(a.getFirstChild());
1✔
399
        b = findNextTextOrElementNode(b.getFirstChild());
1✔
400
        while (!(a == null || b == null)) {
1!
401
            final int nodeTypeA = getEffectiveNodeType(a);
1✔
402
            final int nodeTypeB = getEffectiveNodeType(b);
1✔
403
            if (nodeTypeA != nodeTypeB) {
1✔
404
                return Constants.INFERIOR;
1✔
405
            }
406
            switch (nodeTypeA) {
1!
407
            case Node.TEXT_NODE:
408
                final String nodeValueA = a.getNodeValue();
1✔
409
                final String nodeValueB = b.getNodeValue();
1✔
410
                final int textComparison = safeCompare(nodeValueA, nodeValueB, collator);
1✔
411
                if (textComparison != Constants.EQUAL) {
1✔
412
                    return textComparison;
1✔
413
                }
414
                break;
415
            case Node.ELEMENT_NODE:
416
                final int elementComparison = compareElements(a, b, collator);
1✔
417
                if (elementComparison != Constants.EQUAL) {
1✔
418
                    return elementComparison;
1✔
419
                }
420
                break;
421
            default:
422
                throw new RuntimeException("unexpected node type " + nodeTypeA);
×
423
            }
424
            a = findNextTextOrElementNode(a.getNextSibling());
1✔
425
            b = findNextTextOrElementNode(b.getNextSibling());
1✔
426
        }
427

428
        // NOTE(AR): intentional reference equality check
429
        if (a == b) {
1✔
430
            return Constants.EQUAL; // both null
1✔
431
        } else if (a == null) {
1!
432
            return Constants.INFERIOR;
1✔
433
        } else {
434
            return Constants.SUPERIOR;
×
435
        }
436
    }
437

438
    private static Node findNextTextOrElementNode(Node n) {
439
        for(;;) {
1✔
440
            if (n == null) {
1✔
441
                return null;
1✔
442
            }
443
            final int nodeType = getEffectiveNodeType(n);
1✔
444
            if (nodeType == Node.ELEMENT_NODE || nodeType == Node.TEXT_NODE) {
1✔
445
                return n;
1✔
446
            }
447
            n = n.getNextSibling();
1✔
448
        }
449
    }
450

451
    private static int getEffectiveNodeType(final Node n) {
452
        return n.getNodeType();
1✔
453
    }
454

455
    private static int compareAttributes(final Node a, final Node b, @Nullable final Collator collator) {
456
        final NamedNodeMap nnma = a.getAttributes();
1✔
457
        final NamedNodeMap nnmb = b.getAttributes();
1✔
458

459
        final int aCount = getAttrCount(nnma);
1✔
460
        final int bCount = getAttrCount(nnmb);
1✔
461

462
        if (aCount == bCount) {
1✔
463
            for (int i = 0; i < nnma.getLength(); i++) {
1✔
464
                final Node ta = nnma.item(i);
1✔
465
                final String nsA = ta.getNamespaceURI();
1✔
466
                if (nsA != null && Namespaces.XMLNS_NS.equals(nsA)) {
1✔
467
                    continue;
1✔
468
                }
469
                final Node tb = ta.getLocalName() == null ? nnmb.getNamedItem(ta.getNodeName()) : nnmb.getNamedItemNS(ta.getNamespaceURI(), ta.getLocalName());
1!
470
                if (tb == null) {
1✔
471
                    return Constants.SUPERIOR;
1✔
472
                }
473
                final int comparison = safeCompare(ta.getNodeValue(), tb.getNodeValue(), collator);
1✔
474
                if (comparison != Constants.EQUAL) {
1✔
475
                    return comparison;
1✔
476
                }
477
            }
478

479
            return Constants.EQUAL;
1✔
480
        } else {
481
            return aCount < bCount ? Constants.INFERIOR : Constants.SUPERIOR;
1!
482
        }
483
    }
484

485
    /**
486
     * Return the number of real attributes in the map. Filter out
487
     * xmlns namespace attributes.
488
     */
489
    private static int getAttrCount(final NamedNodeMap nnm) {
490
        int count = 0;
1✔
491
        for (int i = 0; i < nnm.getLength(); i++) {
1✔
492
            final Node n = nnm.item(i);
1✔
493
            final String ns = n.getNamespaceURI();
1✔
494
            if (ns == null || !Namespaces.XMLNS_NS.equals(ns)) {
1✔
495
                ++count;
1✔
496
            }
497
        }
498
        return count;
1✔
499
    }
500

501
    private static int compareNames(final Node a, final Node b) {
502
        if (a.getLocalName() != null || b.getLocalName() != null) {
1!
503
            final int nsComparison = safeCompare(a.getNamespaceURI(), b.getNamespaceURI(), null);
1✔
504
            if (nsComparison != Constants.EQUAL) {
1✔
505
                return nsComparison;
1✔
506
            }
507
            return safeCompare(a.getLocalName(), b.getLocalName(), null);
1✔
508
        }
509
        return safeCompare(a.getNodeName(), b.getNodeName(), null);
×
510
    }
511

512
    private static int safeCompare(@Nullable final String a, @Nullable final String b, @Nullable final Collator collator) {
513
        // NOTE(AR): intentional reference equality check
514
        if (a == b) {
1✔
515
            return Constants.EQUAL;
1✔
516
        }
517

518
        if (a == null) {
1!
519
            return Constants.INFERIOR;
×
520
        }
521

522
        if (b == null) {
1!
523
            return Constants.SUPERIOR;
×
524
        }
525

526
        if (collator != null) {
1!
527
            return collator.compare(a, b);
×
528
        } else {
529
            return a.compareTo(b);
1✔
530
        }
531
    }
532
}
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