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

Haixing-Hu / js-common-decorator / 1970a515-3d5d-49ef-bafd-31c9a52cf4e9

10 Nov 2024 05:00PM UTC coverage: 80.83% (-1.5%) from 82.353%
1970a515-3d5d-49ef-bafd-31c9a52cf4e9

push

circleci

Haixing-Hu
feat: make @Normalizable support normalization of enum class field

481 of 608 branches covered (79.11%)

Branch coverage included in aggregate %.

90 of 116 new or added lines in 19 files covered. (77.59%)

20 existing lines in 4 files now uncovered.

649 of 790 relevant lines covered (82.15%)

227.51 hits per line

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

78.02
/src/impl/model/assign-impl.js
1
////////////////////////////////////////////////////////////////////////////////
2
//
3
//    Copyright (c) 2022 - 2023.
4
//    Haixing Hu, Qubit Co. Ltd.
5
//
6
//    All rights reserved.
7
//
8
////////////////////////////////////////////////////////////////////////////////
9
import clone from '@haixing_hu/clone';
10
import { isBuiltInClass } from '@haixing_hu/type-detect';
11
import { isUndefinedOrNull } from '@haixing_hu/common-util';
12
import CLONE_OPTIONS from './clone-options';
13
import DefaultOptions from '../../default-options';
14
import defaultNormalizer from '../../default-normalizer';
15
import getClassMetadata from '../utils/get-class-metadata';
16
import getDefaultInstance from '../utils/get-default-instance';
17
import getExistFieldWithDifferentNamingStyle from '../utils/get-exist-field-with-different-naming-style';
18
import getSourceField from '../utils/get-source-field';
19
import getFieldElementType from '../utils/get-field-element-type';
20
import getFieldType from '../utils/get-field-type';
21
import hasPrototypeFunction from '../utils/has-prototype-function';
22
import ofValueImpl from '../enum/of-value-impl';
23
import { KEY_CLASS_CATEGORY } from '../metadata-keys';
24

25
const Impl = {
35✔
26
  /**
27
   * Gets the names of all fields of the default instance of target class.
28
   *
29
   * If the parent class of the target class has an `assign()` method, the
30
   * `assign()` method of the parent class is called firstly, and then the
31
   * fields owned by the parent class are excluded.
32
   *
33
   * @param {object} target
34
   *     The target object to be assigned to.
35
   * @param {object} source
36
   *     The source object to assigned from, which can be `null` or `undefined`,
37
   *     or a plain old JavaScript object without class information.
38
   * @param {function} type
39
   *     The type of the target object, i.e., the constructor of the class of
40
   *     the target object.
41
   * @param {object} options
42
   *     the additional options for the assignment.
43
   * @returns {string[]}
44
   *     The names of all fields of the default instance of the target class,
45
   *     excluding the fields owned by the parent class of the target class if
46
   *     the parent class has an `assign()` method.
47
   * @author Haixing Hu
48
   * @private
49
   */
50
  getAllFields(target, source, { type, options }) {
51
    // Get all names of fields of the default object of the `type`
52
    const fields = Object.keys(target);
316✔
53
    // call the `assign()` method in the parent classes of the target class
54
    const parentType = Object.getPrototypeOf(type);
316✔
55
    if (hasPrototypeFunction(parentType, 'assign')) {
316✔
56
      // If the parent class or its ancestor classes has an `assign()` method,
57
      // call the `assign()` method of the parent class.
58
      parentType.prototype.assign.call(target, source, options);
5✔
59
      // then exclude fields owned by the parent class
60
      const parentInstance = getDefaultInstance(parentType);
5✔
61
      const parentFields = Object.keys(parentInstance);
5✔
62
      return fields.filter((f) => !parentFields.includes(f));
20✔
63
    } else {
64
      return fields;
311✔
65
    }
66
  },
67

68
  /**
69
   * Copies all configurable, enumerable, and non-read-only properties of the
70
   * source object to the target object without naming conversion.
71
   *
72
   * @param {object} target
73
   *     The target object.
74
   * @param {object} source
75
   *     The source object, which may be `null` or `undefined`.
76
   * @author Haixing Hu
77
   * @private
78
   */
79
  copyPropertiesWithoutNamingConversion(target, source) {
80
    if (isUndefinedOrNull(source)) {
4!
81
      return;
×
82
    }
83
    Object.keys(target).forEach((key) => {
4✔
84
      if (Object.prototype.hasOwnProperty.call(source, key)) {
24!
85
        target[key] = clone(source[key], CLONE_OPTIONS);
24✔
86
      }
87
    });
88
  },
89

90
  /**
91
   * Clones an enumeration value.
92
   *
93
   * @param {string|any} source
94
   *    The source value being cloned.
95
   * @param {string} sourcePath
96
   *     The path of the source object in the property tree of the original root
97
   *     object.
98
   * @param {Function} targetType
99
   *    The constructor of the enumeration class of the target value. Note that
100
   *    this enumeration class **must** be decorated by `@Enum`.
101
   * @param {any|undefined} defaultInstance
102
   *    The default instance of the source value.
103
   * @return {targetType|*|null}
104
   *    The target value deeply cloned from the source value, whose type is
105
   *    exactly the same as the specified type.
106
   * @author Haixing Hu
107
   * @private
108
   */
109
  cloneEnum(source, { sourcePath, targetType, defaultInstance }) {
110
    if (source === null) {
155✔
111
      return null;
1✔
112
    } else if (source === undefined) {
154!
NEW
113
      return defaultInstance;
×
114
    } else if (source instanceof targetType) {
154✔
115
      return source;  // enumerators are singletons
26✔
116
    } else if (typeof source === 'string') {
128!
117
      // the blank string is treated as null enumerator
118
      if (source.trim().length === 0) {
128✔
119
        return defaultInstance;
7✔
120
      }
121
      // convert the string representation to the enumerator
122
      const e = ofValueImpl(targetType, source);
121✔
123
      if (e === undefined) {
121✔
124
        throw new RangeError(`The value of property '${sourcePath}' of the `
3✔
125
          + `source object is not an enumerator of ${targetType.name}: ${source}`);
126
      }
127
      return e;
118✔
128
    } else {
UNCOV
129
      throw new RangeError(`The value of property '${sourcePath}' of the source `
×
130
        + `object is not an enumerator of ${targetType.name}: ${source}`);
131
    }
132
  },
133

134
  /**
135
   * Clones an object of a known type.
136
   *
137
   * @param {object} source
138
   *     The source object being cloned.
139
   * @param {string} targetPath
140
   *     The path of the target object in the property tree of the original root
141
   *     object.
142
   * @param {string} sourcePath
143
   *     The path of the source object in the property tree of the original root
144
   *     object.
145
   * @param {function} targetType
146
   *     The constructor of the class of the cloned target object.
147
   * @param {object} defaultInstance
148
   *     A default instance in the class of the target object. If a certain
149
   *     attribute value of the target object does not exist in the source
150
   *     object or is null, the corresponding attribute value of this default
151
   *     instance is used to assign the corresponding attribute of the target
152
   *     object. Note that this argument may be `null` or `undefined`.
153
   * @param {object} options
154
   *     the additional options for the assignment.
155
   * @returns
156
   *     A target object deeply cloned from the source object, whose type is
157
   *     exactly the same as the specified type.
158
   * @author Haixing Hu
159
   * @private
160
   */
161
  cloneWithType(source, { targetPath, sourcePath, targetType, defaultInstance, options }) {
162
    if (isUndefinedOrNull(source)) {
959!
163
      // If the source object is nullish, directly deep clone the default object
164
      // without naming conversion
UNCOV
165
      return clone(defaultInstance, CLONE_OPTIONS);
×
166
    }
167
    if (isBuiltInClass(targetType)) {
959✔
168
      // For JS built-in standard types, directly deep clone the source object
169
      // without naming conversion
170
      if (source === null) {
644!
NEW
171
        return null;
×
172
      } else if (source === undefined) {
644!
NEW
173
        return defaultInstance;
×
174
      } else if (source.constructor === targetType) {
644✔
175
        return clone(source, CLONE_OPTIONS);
641✔
176
      } else {
177
        console.warn(`The value of the property '${sourcePath}' of the source `
3✔
178
          + `object is not an instance of ${targetType.name}:`, source);
179
        // Still clone the source object even if it is not an instance of the
180
        // target type
181
        return clone(source, CLONE_OPTIONS);
3✔
182
      }
183
    }
184
    const category = getClassMetadata(targetType, KEY_CLASS_CATEGORY);
315✔
185
    switch (category) {
315✔
186
      case 'enum':
187
        return this.cloneEnum(source, {
152✔
188
          sourcePath,
189
          targetType,
190
          defaultInstance,
191
        });
192
      case 'model':
193
      default: {
194
        // Construct a target value based on the source type
195
        /* eslint-disable-next-line new-cap */
196
        const target = new targetType();
163✔
197
        // Recursively assign each attribute value of `source` to `target`
198
        return this.doAssign(target, source, {
163✔
199
          targetPath,
200
          sourcePath,
201
          targetType,
202
          defaultInstance,
203
          options,
204
        });
205
      }
206
    }
207
  },
208

209
  /**
210
   * Clones an array whose element types are known.
211
   *
212
   * @param {object} sourceArray
213
   *     The source array being cloned.
214
   * @param {string} targetPath
215
   *     The path of the target array in the property tree of the original root
216
   *     object.
217
   * @param {string} sourcePath
218
   *     The path of the source object in the property tree of the original root
219
   *     object.
220
   * @param {function} elementType
221
   *     The constructor of the class of the target array elements to be cloned.
222
   * @param {object} defaultArray
223
   *     The default array, which may be `undefined` or `null`.
224
   * @param {object} options
225
   *     the additional options for the assignment.
226
   * @returns
227
   *     A target array deeply cloned from the source array, whose element type
228
   *     is exactly the same as the specified element type.
229
   * @author Haixing Hu
230
   * @private
231
   */
232
  cloneArrayWithElementType(sourceArray, { targetPath, sourcePath, elementType, defaultArray, options }) {
233
    if (!Array.isArray(sourceArray)) {
18!
234
      console.error(`The value of the property '${sourcePath}' of the source `
×
235
        + 'object should be an array:', sourceArray);
236
      // clone the default array without naming conversion
UNCOV
237
      return clone(defaultArray, CLONE_OPTIONS);
×
238
    }
239
    if (isBuiltInClass(elementType)) {
18✔
240
      // For JS built-in standard element types, directly deep clone the array
241
      // without naming conversion
242
      return clone(sourceArray, CLONE_OPTIONS);
4✔
243
    }
244
    const elementTypeCategory = getClassMetadata(elementType, KEY_CLASS_CATEGORY);
14✔
245
    switch (elementTypeCategory) {
14✔
246
      case 'enum':
247
        // For enumeration classes, because enumeration types are always
248
        // expressed in string form, we only need to copy the source string array.
249
        return sourceArray.map((sourceElement, index) => this.cloneEnum(sourceElement, {
3✔
250
          sourcePath: `${sourcePath}[${index}]`,
251
          targetType: elementType,
252
          defaultInstance: (defaultArray ? defaultArray[index] : undefined),
3!
253
        }));
254
      case 'model':
255
      default: {
256
        // For non-enumerated element classes, create a target array of the same
257
        // element type, and assign the values in the source array to the target
258
        // array one by one.
259
        const defaultElement = getDefaultInstance(elementType);
13✔
260
        return sourceArray.map((sourceElement, index) => {
13✔
261
          if (isUndefinedOrNull(sourceElement)) {
34✔
262
            // If the source element is nullish, directly returns it
263
            return sourceElement;
7✔
264
          }
265
          // Create an element from the target array
266
          /* eslint-disable-next-line new-cap */
267
          const targetElement = new elementType();
27✔
268
          // Recursively assign each attribute value of `sourceElement` to `targetElement`.
269
          // Use `defaultElement` as default instance
270
          const defaultInstance = ((defaultArray && defaultArray[index]) ? defaultArray[index] : defaultElement);
27✔
271
          // Recursively assign each element of `sourceArray` to `targetArray`
272
          this.doAssign(targetElement, sourceElement, {
27✔
273
            targetPath: `${targetPath}[${index}]`,
274
            sourcePath: `${sourcePath}[${index}]`,
275
            targetType: elementType,
276
            defaultInstance,
277
            options,
278
          });
279
          return targetElement;
27✔
280
        });
281
      }
282
    }
283
  },
284

285
  /**
286
   * Clones an object of an unknown type.
287
   *
288
   * @param {object} source
289
   *     The source object being cloned.
290
   * @param {string} targetPath
291
   *     The path of the target object in the property tree of the original root
292
   *     object.
293
   * @param {string} sourcePath
294
   *     The path of the source object in the property tree of the original root
295
   *     object.
296
   * @param {object} defaultInstance
297
   *     A default instance in the class of the target object. If a certain
298
   *     attribute value of the target object does not exist in the source
299
   *     object or is null, the corresponding attribute value of this default
300
   *     instance is used to assign the corresponding attribute of the target
301
   *     object. Note that this argument may be `null` or `undefined`.
302
   * @param {object} options
303
   *     the additional options for the assignment.
304
   * @returns
305
   *     A target object deeply cloned from the source object, whose type is
306
   *     exactly the same as the default object.
307
   * @author Haixing Hu
308
   * @private
309
   */
310
  cloneNoType(source, { targetPath, sourcePath, defaultInstance, options }) {
311
    // If the default instance is nullish, directly deep clone the source object
UNCOV
312
    if (isUndefinedOrNull(defaultInstance)) {
×
313
      // clone the source with naming conversion options
UNCOV
314
      return clone(source, {
×
315
        ...CLONE_OPTIONS,
316
        convertNaming: options.convertNaming,
317
        sourceNamingStyle: options.sourceNamingStyle,
318
        targetNamingStyle: options.targetNamingStyle,
319
      });
320
    }
321
    // If the property value of the target object is an object, we must create
322
    // an object of the same prototype and copy the property values of the
323
    // source object
UNCOV
324
    const Class = Object.getPrototypeOf(defaultInstance).constructor;
×
UNCOV
325
    const target = new Class();
×
326
    // Recursively assign each attribute value of `source` to `target`
UNCOV
327
    return this.doAssign(target, source, {
×
328
      targetPath,
329
      sourcePath,
330
      targetType: Class,
331
      defaultInstance,
332
      options,
333
    });
334
  },
335

336
  /**
337
   * Clones an array whose element type is unknown.
338
   *
339
   * @param {array} sourceArray
340
   *     The source array being cloned.
341
   * @param {string} targetPath
342
   *     The path of the target array in the property tree of the original root
343
   *     object.
344
   * @param {string} sourcePath
345
   *     The path of the source object in the property tree of the original root
346
   *     object.
347
   * @param {array} defaultArray
348
   *     The default array, which may be `undefined` or `null`.
349
   * @param {object} options
350
   *     the additional options for the assignment.
351
   * @returns
352
   *     A target array deeply cloned from the source array, whose element type
353
   *     is exactly the same as the element type of the default array.
354
   * @author Haixing Hu
355
   * @private
356
   */
357
  cloneArrayNoElementType(sourceArray, { sourcePath, defaultArray, options }) {
358
    // TODO: If there is type information in its default field value, we can
359
    //  construct an array of the same type based on the type information in
360
    //  the default field value.
UNCOV
361
    if (Array.isArray(sourceArray)) {
×
362
      // clone the source array with naming conversion options
UNCOV
363
      return clone(sourceArray, {
×
364
        ...CLONE_OPTIONS,
365
        convertNaming: options.convertNaming,
366
        sourceNamingStyle: options.sourceNamingStyle,
367
        targetNamingStyle: options.targetNamingStyle,
368
      });
369
    } else {
UNCOV
370
      console.error(`The value of the property '${sourcePath}' of the source `
×
371
        + 'object should be an array:', sourceArray);
372
      // clone the default array without naming conversion
UNCOV
373
      return clone(defaultArray, CLONE_OPTIONS);
×
374
    }
375
  },
376

377
  /**
378
   * Copies all properties of a source object to a target object.
379
   *
380
   * @param {object} target
381
   *     The target object to be assigned to.
382
   * @param {object} source
383
   *     The source object to be assigned from, which can be `null` or `undefined`,
384
   *     or a plain old JavaScript object without class information.
385
   * @param {string} targetPath
386
   *     The path of the target object in the property tree of the original root
387
   *     object.
388
   * @param {string} sourcePath
389
   *     The path of the source object in the property tree of the original root
390
   *     object.
391
   * @param {function} targetType
392
   *     The type of the target object, i.e., the constructor of the class of
393
   *     the target object.
394
   * @param {object} defaultInstance
395
   *     A default instance in the class of the target object. If a certain
396
   *     attribute value of the target object does not exist in the source
397
   *     object or is null, the corresponding attribute value of this default
398
   *     instance is used to assign the corresponding attribute of the target
399
   *     object. Note that this argument may be `null` or `undefined`.
400
   * @param {object} options
401
   *     the additional options for the assignment.
402
   * @returns
403
   *     The target object after assignment.
404
   * @author Haixing Hu
405
   * @private
406
   */
407
  doAssign(target, source, { targetPath, sourcePath, targetType, defaultInstance, options }) {
408
    if (isUndefinedOrNull(source)) {
320✔
409
      // if source is nullish, assign the default object to the target object
410
      this.copyPropertiesWithoutNamingConversion(target, defaultInstance);
4✔
411
    } else {
412
      // Loops over all enumerable properties of the default instance,
413
      // excluding those inherited from the parent class
414
      const theDefaultInstance = defaultInstance ?? getDefaultInstance(targetType);
316✔
415
      const targetKeys = this.getAllFields(target, source, { type: targetType, options });
316✔
416
      targetKeys.forEach((targetField) => {
316✔
417
        const defaultFieldValue = theDefaultInstance[targetField];
1,119✔
418
        const targetFieldPath = `${targetPath}.${targetField}`;
1,119✔
419
        const sourceField = getSourceField(targetField, options);
1,119✔
420
        const sourceFieldPath = `${sourcePath}.${sourceField}`;
1,119✔
421
        const sourceFieldValue = source[sourceField];
1,119✔
422
        // If field of the target object is annotated with `@Type`, or the
423
        // `options.types` has the type information of the field, get the field type
424
        const targetFieldType = getFieldType(targetType, targetField, targetFieldPath, options);
1,119✔
425
        // If field of the target object is annotated with `@ElementType`,
426
        // or the `options.elementTypes` has the type information of the
427
        // field element, get the field element type
428
        const targetFieldElementType = getFieldElementType(targetType, targetField, targetFieldPath, options);
1,119✔
429
        if (!Object.prototype.hasOwnProperty.call(source, sourceField)) {
1,119✔
430
          // If the source object does not have the field
431
          // warn if the source object has a field with different naming style
432
          const existSourceField = getExistFieldWithDifferentNamingStyle(sourceField, source);
98✔
433
          if (existSourceField) {
98✔
434
            console.warn(`Cannot find the source property '${sourceFieldPath}' `
3✔
435
              + `for the target property '${targetFieldPath}'. `
436
              + `But the source object has a property '${sourcePath}.${existSourceField}'. `
437
              + 'A correct naming conversion may be needed.');
438
            console.warn('The source object:', source);
3✔
439
            console.warn('The target object:', target);
3✔
440
          }
441
          // and then copy the default field value directly.
442
          target[targetField] = clone(defaultFieldValue, CLONE_OPTIONS);
98✔
443
        } else if (isUndefinedOrNull(sourceFieldValue)) {
1,021✔
444
          // If the source object has the field, but the field value is nullish,
445
          // copy the nullish source field value directly.
446
          target[targetField] = sourceFieldValue;
31✔
447
        } else if (targetFieldElementType) {
990✔
448
          // If the field of the target object is annotated with `@ElementType`
449
          target[targetField] = this.cloneArrayWithElementType(sourceFieldValue, {
18✔
450
            targetPath: targetFieldPath,
451
            sourcePath: sourceFieldPath,
452
            elementType: targetFieldElementType,
453
            defaultArray: defaultFieldValue,
454
            options,
455
          });
456
        } else if (targetFieldType) {
972✔
457
          // If the field of the target object is annotated with `@Type`
458
          target[targetField] = this.cloneWithType(sourceFieldValue, {
959✔
459
            targetPath: targetFieldPath,
460
            sourcePath: sourceFieldPath,
461
            targetType: targetFieldType,
462
            defaultInstance: defaultFieldValue,
463
            options,
464
          });
465
        } else if (isUndefinedOrNull(defaultFieldValue)) {
13!
466
          // If the field value of the default instance is nullish, but the
467
          // source object field value is not nullish, and the field is not
468
          // decorated with `@Type`, it is impossible to determine the type of
469
          // the attribute, therefore we directly clone the source object field
470
          // value.
471
          // clone the source field value with the naming conversion options
472
          target[targetField] = clone(sourceFieldValue, {
13✔
473
            ...CLONE_OPTIONS,
474
            convertNaming: options.convertNaming,
475
            sourceNamingStyle: options.sourceNamingStyle,
476
            targetNamingStyle: options.targetNamingStyle,
477
          });
UNCOV
478
        } else if (Array.isArray(defaultFieldValue)) {
×
479
          // If the field value of the target object is an array but has not
480
          // been annotated with `@ElementType`
UNCOV
481
          target[targetField] = this.cloneArrayNoElementType(sourceFieldValue, {
×
482
            targetPath: targetFieldPath,
483
            sourcePath: sourceFieldPath,
484
            defaultArray: defaultFieldValue,
485
            options,
486
          });
UNCOV
487
        } else if ((typeof defaultFieldValue) === 'object') {
×
488
          // If the property value of the target object is a non-null and
489
          // non-array object, we must create an object of the same prototype
490
          // and copy the property value of the source object
UNCOV
491
          target[targetField] = this.cloneNoType(sourceFieldValue, {
×
492
            targetPath: targetFieldPath,
493
            sourcePath: sourceFieldPath,
494
            defaultInstance: defaultFieldValue,
495
            options,
496
          });
497
        } else {
498
          // If the attribute value of the target object is not an object,
499
          // directly clone the attribute value of the source object and assign
500
          // it to the target object.
UNCOV
501
          target[targetField] = clone(sourceFieldValue, {
×
502
            ...CLONE_OPTIONS,
503
            convertNaming: options.convertNaming,
504
            sourceNamingStyle: options.sourceNamingStyle,
505
            targetNamingStyle: options.targetNamingStyle,
506
          });
507
        }
508
      });
509
    }
510
    if (options.normalize) {
314✔
511
      return defaultNormalizer(target);
271✔
512
    } else {
513
      return target;
43✔
514
    }
515
  },
516
};
517

518
/**
519
 * Assigns the specified source object to the specified target object.
520
 *
521
 * @param {function|ObjectConstructor} Class
522
 *     The constructor of the class of the target object.
523
 * @param {object} target
524
 *     The target object which will be assigned to. This object must be an
525
 *     instance of the specified `Class`. Each fields of this object will be
526
 *     assigned from the corresponding fields of the source object, recursively.
527
 * @param {object} source
528
 *     The source object which will be assigned from. This object may be any
529
 *     plain old JavaScript object without class information.
530
 * @param {null|undefined|object} options
531
 *     the additional options for the assignment. If this argument is
532
 *     `undefined` or `null`, the default options will be used. The default
533
 *     options can be retrieved by calling `DefaultOptions.get('assign')`.
534
 *     Available options are:
535
 *     - `normalize: boolean`, indicates whether to normalize this object
536
 *       after the assignment. The default value is `true`.
537
 *     - `convertNaming: boolean`, indicates whether to convert the naming
538
 *       style of the target object. The default value is `false`.
539
 *     - `sourceNamingStyle: string | NamingStyle`, the naming style of the
540
 *       source object, i.e., the first argument of the `assign()` method.
541
 *       The default value of this argument is {@link LOWER_UNDERSCORE}.
542
 *     - `targetNamingStyle: string | NamingStyle`, the naming style of the
543
 *       target object, i.e., the object calling the `assign()` method. The
544
 *       default value of this argument is {@link LOWER_CAMEL}.
545
 * @return {Class}
546
 *     The target object after assignment.
547
 * @see DefaultOptions.get('assign')
548
 * @author Haixing Hu
549
 * @private
550
 */
551
function assignImpl(Class, target, source, options) {
552
  const opt = DefaultOptions.merge('assign', options);
130✔
553
  // If the source object is of the same class with the target object,
554
  // the naming conversion must be disabled
555
  if (source instanceof Class) {
130✔
556
    opt.convertNaming = false;
13✔
557
  }
558
  return Impl.doAssign(target, source, {
130✔
559
    targetPath: '',
560
    sourcePath: '',
561
    targetType: Class,
562
    defaultInstance: getDefaultInstance(Class),
563
    options: opt,
564
  });
565
}
566

567
export default assignImpl;
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