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

realm / realm-js / 6015142867

29 Aug 2023 04:51PM UTC coverage: 85.251%. Remained the same
6015142867

push

github

LJ
Update error message to account for 'objectType' being a Constructor.

881 of 1096 branches covered (0.0%)

Branch coverage included in aggregate %.

2298 of 2633 relevant lines covered (87.28%)

797.4 hits per line

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

83.76
/packages/realm/src/Object.ts
1
////////////////////////////////////////////////////////////////////////////
2
//
3
// Copyright 2022 Realm Inc.
4
//
5
// Licensed under the Apache License, Version 2.0 (the "License");
6
// you may not use this file except in compliance with the License.
7
// You may obtain a copy of the License at
8
//
9
// http://www.apache.org/licenses/LICENSE-2.0
10
//
11
// Unless required by applicable law or agreed to in writing, software
12
// distributed under the License is distributed on an "AS IS" BASIS,
13
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
// See the License for the specific language governing permissions and
15
// limitations under the License.
16
//
17
////////////////////////////////////////////////////////////////////////////
18

19
import {
20
  AssertionError,
21
  BSON,
22
  CanonicalObjectSchema,
23
  ClassHelpers,
24
  Constructor,
25
  DefaultObject,
26
  Dictionary,
27
  JSONCacheMap,
28
  ObjectChangeCallback,
29
  ObjectListeners,
30
  OmittedRealmTypes,
31
  OrderedCollection,
32
  OrderedCollectionHelpers,
33
  Realm,
34
  RealmObjectConstructor,
35
  Results,
36
  TypeAssertionError,
37
  Unmanaged,
38
  assert,
39
  binding,
40
  flags,
41
  getTypeName,
42
} from "./internal";
43

44
/**
45
 * The update mode to use when creating an object that already exists.
46
 */
47
export enum UpdateMode {
1✔
48
  /**
49
   * Objects are only created. If an existing object exists, an exception is thrown.
50
   */
51
  Never = "never",
1✔
52
  /**
53
   * If an existing object exists, only properties where the value has actually
54
   * changed will be updated. This improves notifications and server side
55
   * performance but also have implications for how changes across devices are
56
   * merged. For most use cases, the behavior will match the intuitive behavior
57
   * of how changes should be merged, but if updating an entire object is
58
   * considered an atomic operation, this mode should not be used.
59
   */
60
  Modified = "modified",
1✔
61
  /**
62
   * If an existing object is found, all properties provided will be updated,
63
   * any other properties will remain unchanged.
64
   */
65
  All = "all",
1✔
66
}
67

68
/** @internal */
69
export type ObjCreator = () => [binding.Obj, boolean];
70

71
type CreationContext = {
72
  helpers: ClassHelpers;
73
  createObj?: ObjCreator;
74
};
75

76
// eslint-disable-next-line @typescript-eslint/no-explicit-any
77
export type AnyRealmObject = RealmObject<any>;
78

79
export const KEY_ARRAY = Symbol("Object#keys");
1✔
80
export const KEY_SET = Symbol("Object#keySet");
1✔
81
export const REALM = Symbol("Object#realm");
1✔
82
export const INTERNAL = Symbol("Object#internal");
1✔
83
const INTERNAL_LISTENERS = Symbol("Object#listeners");
1✔
84
export const INTERNAL_HELPERS = Symbol("Object.helpers");
1✔
85
const DEFAULT_PROPERTY_DESCRIPTOR: PropertyDescriptor = { configurable: true, enumerable: true, writable: true };
1✔
86

87
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
88
const PROXY_HANDLER: ProxyHandler<RealmObject<any>> = {
1✔
89
  ownKeys(target) {
90
    return Reflect.ownKeys(target).concat(target[KEY_ARRAY]);
64✔
91
  },
92
  getOwnPropertyDescriptor(target, prop) {
93
    if (typeof prop === "string" && target[KEY_SET].has(prop)) {
160✔
94
      return DEFAULT_PROPERTY_DESCRIPTOR;
155✔
95
    }
96
    const result = Reflect.getOwnPropertyDescriptor(target, prop);
5✔
97
    if (result && typeof prop === "symbol") {
5✔
98
      if (prop === INTERNAL) {
3!
99
        result.enumerable = false;
×
100
        result.writable = false;
×
101
      } else if (prop === INTERNAL_LISTENERS) {
3✔
102
        result.enumerable = false;
3✔
103
      }
104
    }
105
    return result;
5✔
106
  },
107
};
108

109
/**
110
 * Base class for a Realm Object.
111
 * @example
112
 * To define a class `Person` with required `name` and `age`
113
 * properties, define a `static schema`:
114
 * ```
115
 * class Person extends Realm.Object<Person> {
116
 *   _id!: Realm.BSON.ObjectId;
117
 *   name!: string;
118
 *   age!: number;
119
 *   static schema: Realm.ObjectSchema = {
120
 *     name: "Person",
121
 *     primaryKey: "_id",
122
 *     properties: {
123
 *       _id: "objectId",
124
 *       name: "string",
125
 *       age: "int",
126
 *     },
127
 *   };
128
 * }
129
 * ```
130
 * @example
131
 * If using the [@realm/babel-plugin](https://www.npmjs.com/package/@realm/babel-plugin):
132
 * To define a class `Person` with required `name` and `age` properties, they would
133
 * need to be specified in the type argument when it is being constructed to allow
134
 * Typescript-only model definitions:
135
 * ```
136
 * class Person extends Realm.Object<Person, "name" | "age"> {
137
 *   _id = new Realm.Types.ObjectId();
138
 *   name: Realm.Types.String;
139
 *   age: Realm.Types.Int;
140
 *   static primaryKey = "_id";
141
 * }
142
 * ```
143
 * @see {@link ObjectSchema}
144
 * @typeParam `T` - The type of this class (e.g. if your class is `Person`,
145
 * `T` should also be `Person` - this duplication is required due to how
146
 * TypeScript works)
147
 * @typeParam `RequiredProperties` - The names of any properties of this
148
 * class which are required when an instance is constructed with `new`. Any
149
 * properties not specified will be optional, and will default to a sensible
150
 * null value if no default is specified elsewhere.
151
 */
152
export class RealmObject<T = DefaultObject, RequiredProperties extends keyof OmittedRealmTypes<T> = never> {
153
  /**
154
   * This property is stored on the per class prototype when transforming the schema.
155
   * @internal
156
   */
157
  public static [INTERNAL_HELPERS]: ClassHelpers;
158

159
  public static allowValuesArrays = false;
1✔
160

161
  /**
162
   * Optionally specify the primary key of the schema when using [@realm/babel-plugin](https://www.npmjs.com/package/@realm/babel-plugin).
163
   */
164
  static primaryKey?: string;
165

166
  /**
167
   * Optionally specify that the schema is an embedded schema when using [@realm/babel-plugin](https://www.npmjs.com/package/@realm/babel-plugin).
168
   */
169
  static embedded?: boolean;
170

171
  /**
172
   * Optionally specify that the schema should sync unidirectionally if using flexible sync when using [@realm/babel-plugin](https://www.npmjs.com/package/@realm/babel-plugin).
173
   */
174
  static asymmetric?: boolean;
175

176
  /**
177
   * Create an object in the database and set values on it
178
   * @internal
179
   */
180
  public static create(
181
    realm: Realm,
182
    values: Record<string, unknown>,
183
    mode: UpdateMode,
184
    context: CreationContext,
185
  ): RealmObject {
186
    assert.inTransaction(realm);
2,899✔
187
    if (Array.isArray(values)) {
2,898✔
188
      if (flags.ALLOW_VALUES_ARRAYS) {
23!
189
        const { persistedProperties } = context.helpers.objectSchema;
23✔
190
        return RealmObject.create(
23✔
191
          realm,
192
          Object.fromEntries(
193
            values.map((value, index) => {
194
              const property = persistedProperties[index];
30✔
195
              const propertyName = property.publicName || property.name;
30✔
196
              return [propertyName, value];
30✔
197
            }),
198
          ),
199
          mode,
200
          context,
201
        );
202
      } else {
203
        throw new Error("Array values on object creation is no longer supported");
×
204
      }
205
    }
206
    const {
207
      helpers: {
208
        properties,
209
        wrapObject,
210
        objectSchema: { persistedProperties },
211
      },
212
      createObj,
213
    } = context;
2,875✔
214

215
    // Create the underlying object
216
    const [obj, created] = createObj ? createObj() : this.createObj(realm, values, mode, context);
2,875✔
217
    const result = wrapObject(obj);
2,866✔
218
    assert(result);
2,866✔
219
    // Persist any values provided
220
    // TODO: Consider using the property helpers directly to improve performance
221
    for (const property of persistedProperties) {
2,866✔
222
      const propertyName = property.publicName || property.name;
8,315✔
223
      const { default: defaultValue } = properties.get(propertyName);
8,315✔
224
      if (property.isPrimary) {
8,315✔
225
        continue; // Skip setting this, as we already provided it on object creation
521✔
226
      }
227
      const propertyValue = values[propertyName];
7,794✔
228
      if (typeof propertyValue !== "undefined") {
7,794✔
229
        if (mode !== UpdateMode.Modified || result[propertyName] !== propertyValue) {
4,853✔
230
          // This will call into the property setter in PropertyHelpers.ts.
231
          // (E.g. the setter for [binding.PropertyType.Array] in the case of lists.)
232
          result[propertyName] = propertyValue;
4,848✔
233
        }
234
      } else {
235
        if (typeof defaultValue !== "undefined") {
2,941✔
236
          result[propertyName] = typeof defaultValue === "function" ? defaultValue() : defaultValue;
79✔
237
        } else if (
2,862✔
238
          !(property.type & binding.PropertyType.Collection) &&
5,256✔
239
          !(property.type & binding.PropertyType.Nullable) &&
240
          created
241
        ) {
242
          throw new Error(`Missing value for property '${propertyName}'`);
7✔
243
        }
244
      }
245
    }
246
    return result as RealmObject;
2,849✔
247
  }
248

249
  /**
250
   * Create an object in the database and populate its primary key value, if required
251
   * @internal
252
   */
253
  private static createObj(
254
    realm: Realm,
255
    values: DefaultObject,
256
    mode: UpdateMode,
257
    context: CreationContext,
258
  ): [binding.Obj, boolean] {
259
    const {
260
      helpers: {
261
        objectSchema: { name, tableKey, primaryKey },
262
        properties,
263
      },
264
    } = context;
2,720✔
265

266
    // Create the underlying object
267
    const table = binding.Helpers.getTable(realm.internal, tableKey);
2,720✔
268
    if (primaryKey) {
2,720✔
269
      const primaryKeyHelpers = properties.get(primaryKey);
529✔
270
      let primaryKeyValue = values[primaryKey];
529✔
271

272
      // If the value for the primary key was not set, use the default value
273
      if (primaryKeyValue === undefined) {
529✔
274
        const defaultValue = primaryKeyHelpers.default;
2✔
275
        primaryKeyValue = typeof defaultValue === "function" ? defaultValue() : defaultValue;
2!
276
      }
277

278
      const pk = primaryKeyHelpers.toBinding(
529✔
279
        // Fallback to default value if the provided value is undefined or null
280
        typeof primaryKeyValue !== "undefined" && primaryKeyValue !== null
1,587✔
281
          ? primaryKeyValue
282
          : primaryKeyHelpers.default,
283
      );
284

285
      const result = binding.Helpers.getOrCreateObjectWithPrimaryKey(table, pk);
529✔
286
      const [, created] = result;
527✔
287
      if (mode === UpdateMode.Never && !created) {
527✔
288
        throw new Error(
5✔
289
          `Attempting to create an object of type '${name}' with an existing primary key value '${primaryKeyValue}'.`,
290
        );
291
      }
292
      return result;
522✔
293
    } else {
294
      return [table.createObject(), true];
2,191✔
295
    }
296
  }
297

298
  /**
299
   * Create a wrapper for accessing an object from the database
300
   * @internal
301
   */
302
  public static createWrapper<T = DefaultObject>(internal: binding.Obj, constructor: Constructor): RealmObject<T> & T {
303
    const result = Object.create(constructor.prototype);
3,925✔
304
    result[INTERNAL] = internal;
3,925✔
305
    // Initializing INTERNAL_LISTENERS here rather than letting it just be implicitly undefined since JS engines
306
    // prefer adding all fields to objects upfront. Adding optional fields later can sometimes trigger deoptimizations.
307
    result[INTERNAL_LISTENERS] = null;
3,925✔
308

309
    // Wrap in a proxy to trap keys, enabling the spread operator, and hiding our internal fields.
310
    return new Proxy(result, PROXY_HANDLER);
3,925✔
311
  }
312

313
  /**
314
   * Create a `RealmObject` wrapping an `Obj` from the binding.
315
   * @param realm - The Realm managing the object.
316
   * @param values - The values of the object's properties at creation.
317
   */
318
  public constructor(realm: Realm, values: Unmanaged<T, RequiredProperties>) {
319
    return realm.create(this.constructor as RealmObjectConstructor, values) as unknown as this;
25✔
320
  }
321

322
  /**
323
   * The Realm managing the object.
324
   * Note: this is on the injected prototype from ClassMap.defineProperties().
325
   * @internal
326
   */
327
  public declare readonly [REALM]: Realm;
328

329
  /**
330
   * The object's representation in the binding.
331
   * @internal
332
   */
333
  public declare readonly [INTERNAL]: binding.Obj;
334

335
  /**
336
   * Lazily created wrapper for the object notifier.
337
   * @internal
338
   */
339
  private declare [INTERNAL_LISTENERS]: ObjectListeners<T> | null;
340

341
  /**
342
   * Note: this is on the injected prototype from ClassMap.defineProperties()
343
   * @internal
344
   */
345
  private declare readonly [KEY_ARRAY]: ReadonlyArray<string>;
346

347
  /**
348
   * Note: this is on the injected prototype from ClassMap.defineProperties()
349
   * @internal
350
   */
351
  private declare readonly [KEY_SET]: ReadonlySet<string>;
352

353
  /**
354
   * @returns An array of the names of the object's properties.
355
   * @deprecated Please use {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/keys | Object.keys()}
356
   */
357
  keys(): string[] {
358
    // copying to prevent caller from modifying the static array.
359
    return [...this[KEY_ARRAY]];
6✔
360
  }
361

362
  /**
363
   * @returns An array of key/value pairs of the object's properties.
364
   * @deprecated Please use {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries | Object.entries()}
365
   */
366
  entries(): [string, unknown][] {
367
    return Object.entries(this);
1✔
368
  }
369

370
  /**
371
   * The plain object representation for JSON serialization.
372
   * Use circular JSON serialization libraries such as [@ungap/structured-clone](https://www.npmjs.com/package/@ungap/structured-clone)
373
   * and [flatted](https://www.npmjs.com/package/flatted) to stringify Realm entities that have circular structures.
374
   * @returns A plain object.
375
   */
376
  toJSON(_?: string, cache?: unknown): DefaultObject;
377
  /** @internal */
378
  toJSON(_?: string, cache = new JSONCacheMap()): DefaultObject {
12✔
379
    // Construct a reference-id of table-name & primaryKey if it exists, or fall back to objectId.
380

381
    // Check if current objectId has already processed, to keep object references the same.
382
    const existing = cache.find(this);
64✔
383
    if (existing) {
64✔
384
      return existing;
19✔
385
    }
386
    const result: DefaultObject = {};
45✔
387
    cache.add(this, result);
45✔
388
    // Move all enumerable keys to result, triggering any specific toJSON implementation in the process.
389
    for (const key in this) {
45✔
390
      const value = this[key];
90✔
391
      if (typeof value == "function") {
90!
392
        continue;
×
393
      }
394
      if (value instanceof RealmObject || value instanceof OrderedCollection || value instanceof Dictionary) {
90✔
395
        // recursively trigger `toJSON` for Realm instances with the same cache.
396
        result[key] = value.toJSON(key, cache);
30✔
397
      } else {
398
        // Other cases, including null and undefined.
399
        result[key] = value;
60✔
400
      }
401
    }
402
    return result;
45✔
403
  }
404

405
  /**
406
   * Checks if this object has not been deleted and is part of a valid Realm.
407
   * @returns `true` if the object can be safely accessed, `false` if not.
408
   */
409
  isValid(): boolean {
410
    return this[INTERNAL] && this[INTERNAL].isValid;
5✔
411
  }
412

413
  /**
414
   * The schema for the type this object belongs to.
415
   * @returns The {@link CanonicalObjectSchema} that describes this object.
416
   */
417
  objectSchema(): CanonicalObjectSchema<T> {
418
    return this[REALM].getClassHelpers(this).canonicalObjectSchema as CanonicalObjectSchema<T>;
103✔
419
  }
420

421
  /**
422
   * Returns all the objects that link to this object in the specified relationship.
423
   * @param objectType - The type of the objects that link to this object's type.
424
   * @param propertyName - The name of the property that references objects of this object's type.
425
   * @throws An {@link AssertionError} if the relationship is not valid.
426
   * @returns The {@link Results} that link to this object.
427
   */
428
  linkingObjects<T = DefaultObject>(objectType: string, propertyName: string): Results<RealmObject<T> & T>;
429
  linkingObjects<T extends AnyRealmObject>(objectType: Constructor<T>, propertyName: string): Results<T>;
430
  linkingObjects<T extends AnyRealmObject>(objectType: string | Constructor<T>, propertyName: string): Results<T> {
431
    const targetClassHelpers = this[REALM].getClassHelpers(objectType);
7✔
432
    const { objectSchema: targetObjectSchema, properties, wrapObject } = targetClassHelpers;
6✔
433
    const targetProperty = properties.get(propertyName);
6✔
434
    const originObjectSchema = this.objectSchema();
5✔
435

436
    assert(
5✔
437
      originObjectSchema.name === targetProperty.objectType,
438
      () => `'${targetObjectSchema.name}#${propertyName}' is not a relationship to '${originObjectSchema.name}'`,
1✔
439
    );
440

441
    const collectionHelpers: OrderedCollectionHelpers = {
4✔
442
      // See `[binding.PropertyType.LinkingObjects]` in `TypeHelpers.ts`.
443
      toBinding(value: unknown) {
444
        return value as binding.MixedArg;
×
445
      },
446
      fromBinding(value: unknown) {
447
        assert.instanceOf(value, binding.Obj);
8✔
448
        return wrapObject(value);
8✔
449
      },
450
      // See `[binding.PropertyType.Array]` in `PropertyHelpers.ts`.
451
      get(results: binding.Results, index: number) {
452
        return results.getObj(index);
8✔
453
      },
454
    };
455

456
    // Create the Result for the backlink view.
457
    const tableRef = binding.Helpers.getTable(this[REALM].internal, targetObjectSchema.tableKey);
4✔
458
    const tableView = this[INTERNAL].getBacklinkView(tableRef, targetProperty.columnKey);
4✔
459
    const results = binding.Results.fromTableView(this[REALM].internal, tableView);
4✔
460

461
    return new Results(this[REALM], results, collectionHelpers);
4✔
462
  }
463

464
  /**
465
   * Returns the total count of incoming links to this object
466
   * @returns The number of links to this object.
467
   */
468
  linkingObjectsCount(): number {
469
    return this[INTERNAL].getBacklinkCount();
6✔
470
  }
471

472
  /**
473
   * @deprecated
474
   * TODO: Remove completely once the type tests are abandoned.
475
   */
476
  _objectId(): string {
477
    throw new Error("This is now removed!");
×
478
  }
479

480
  /**
481
   * A string uniquely identifying the object across all objects of the same type.
482
   */
483
  _objectKey(): string {
484
    return this[INTERNAL].key.toString();
98✔
485
  }
486

487
  /**
488
   * Add a listener `callback` which will be called when a **live** object instance changes.
489
   * @param callback - A function to be called when changes occur.
490
   * @param callback.obj - The object that changed.
491
   * @param callback.changes - A dictionary with information about the changes.
492
   * @param callback.changes.deleted - Is `true` if the object has been deleted.
493
   * @param callback.changes.changedProperties  - An array of properties that have changed their value.
494
   * @throws A {@link TypeAssertionError} if `callback` is not a function.
495
   * @example
496
   * wine.addListener((obj, changes) => {
497
   *  // obj === wine
498
   *  console.log(`object is deleted: ${changes.deleted}`);
499
   *  console.log(`${changes.changedProperties.length} properties have been changed:`);
500
   *  changes.changedProperties.forEach(prop => {
501
   *      console.log(` ${prop}`);
502
   *   });
503
   * })
504
   * @note Adding the listener is an asynchronous operation, so the callback is invoked the first time to notify the caller when the listener has been added.
505
   * Thus, when the callback is invoked the first time it will contain empty array for `changes.changedProperties`.
506
   */
507
  addListener(callback: ObjectChangeCallback<T>): void {
508
    assert.function(callback);
5✔
509
    if (!this[INTERNAL_LISTENERS]) {
5✔
510
      this[INTERNAL_LISTENERS] = new ObjectListeners<T>(this[REALM].internal, this);
3✔
511
    }
512
    this[INTERNAL_LISTENERS].addListener(callback);
5✔
513
  }
514

515
  /**
516
   * Remove the listener `callback` from this object.
517
   * @throws A {@link TypeAssertionError} if `callback` is not a function.
518
   * @param callback A function previously added as listener
519
   */
520
  removeListener(callback: ObjectChangeCallback<T>): void {
521
    assert.function(callback);
1✔
522
    // Note: if the INTERNAL_LISTENERS field hasn't been initialized, then we have no listeners to remove.
523
    this[INTERNAL_LISTENERS]?.removeListener(callback);
1✔
524
  }
525

526
  /**
527
   * Remove all listeners from this object.
528
   */
529
  removeAllListeners(): void {
530
    // Note: if the INTERNAL_LISTENERS field hasn't been initialized, then we have no listeners to remove.
531
    this[INTERNAL_LISTENERS]?.removeAllListeners();
1✔
532
  }
533

534
  /**
535
   * Get underlying type of a property value.
536
   * @param propertyName - The name of the property to retrieve the type of.
537
   * @throws An {@link Error} if property does not exist.
538
   * @returns Underlying type of the property value.
539
   */
540
  getPropertyType(propertyName: string): string {
541
    const { properties } = this[REALM].getClassHelpers(this);
25✔
542
    const { type, objectType, columnKey } = properties.get(propertyName);
25✔
543
    const typeName = getTypeName(type, objectType);
24✔
544
    if (typeName === "mixed") {
24✔
545
      // This requires actually getting the object and inferring its type
546
      const value = this[INTERNAL].getAny(columnKey);
5✔
547
      if (value === null) {
5✔
548
        return "null";
1✔
549
      } else if (binding.Int64.isInt(value)) {
4!
550
        return "int";
×
551
      } else if (value instanceof binding.Float) {
4!
552
        return "float";
×
553
      } else if (value instanceof binding.Timestamp) {
4!
554
        return "date";
×
555
      } else if (value instanceof binding.Obj) {
4!
556
        const { objectSchema } = this[REALM].getClassHelpers(value.table.key);
×
557
        return `<${objectSchema.name}>`;
×
558
      } else if (value instanceof binding.ObjLink) {
4!
559
        const { objectSchema } = this[REALM].getClassHelpers(value.tableKey);
×
560
        return `<${objectSchema.name}>`;
×
561
      } else if (value instanceof ArrayBuffer) {
4!
562
        return "data";
×
563
      } else if (typeof value === "number") {
4✔
564
        return "double";
2✔
565
      } else if (typeof value === "string") {
2✔
566
        return "string";
1✔
567
      } else if (typeof value === "boolean") {
1!
568
        return "bool";
1✔
569
      } else if (value instanceof BSON.ObjectId) {
×
570
        return "objectId";
×
571
      } else if (value instanceof BSON.Decimal128) {
×
572
        return "decimal128";
×
573
      } else if (value instanceof BSON.UUID) {
×
574
        return "uuid";
×
575
      } else {
576
        assert.never(value, "value");
×
577
      }
578
    } else {
579
      return typeName;
19✔
580
    }
581
  }
582
}
583

584
// We like to refer to this as "Realm.Object"
585
// TODO: Determine if we want to revisit this if we're going away from a namespaced API
586
Object.defineProperty(RealmObject, "name", { value: "Realm.Object" });
1✔
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