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

dataunitylab / relational-playground / #110

11 Sep 2025 02:31PM UTC coverage: 77.346% (-1.2%) from 78.51%
#110

push

michaelmior
Remove deprecated ReactDOM.findDOMNode

Signed-off-by: Michael Mior <mmior@mail.rit.edu>

524 of 741 branches covered (70.72%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

40 existing lines in 10 files now uncovered.

1009 of 1241 relevant lines covered (81.31%)

14408.74 hits per line

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

89.55
/src/modules/data.js
1
// @flow
2
import {deepEqual} from 'fast-equals';
3
import {produce} from 'immer';
4

5
import department from '../resources/Department.json';
6
import doctor from '../resources/Doctor.json';
7
import patient from '../resources/Patient.json';
8

9
export const CHANGE_EXPR = 'CHANGE_EXPR';
11✔
10
export const RESET_EXPR = 'RESET_EXPR';
11✔
11

12
type ChangeAction = {
13
  type: 'CHANGE_EXPR',
14
  expr: {[string]: any},
15
  element: ?HTMLElement,
16
};
17

18
type ResetAction = {
19
  type: 'RESET_EXPR',
20
};
21

22
type Action = ChangeAction | ResetAction;
23

24
type Output = {
25
  name: string,
26
  columns: Array<string>,
27
  data: Array<{[string]: any}>,
28
};
29

30
/**
31
 * @param expr - a relational algebra expression object
32
 * @param element
33
 * @return a new CHANGE_EXPR action
34
 */
35
export function changeExpr(
36
  expr: {[string]: any},
37
  element: ?HTMLElement
38
): ChangeAction {
39
  return {type: CHANGE_EXPR, expr, element};
42✔
40
}
41

42
export function resetAction(): ResetAction {
43
  return {type: RESET_EXPR};
×
44
}
45

46
export type Data = {
47
  name: string,
48
  columns: Array<string>,
49
  data: Array<{[string]: any}>,
50
};
51

52
export type State = {
53
  current?: Data,
54
  sourceData: {[string]: Data},
55
  element: ?HTMLElement,
56
  expr: {[string]: any},
57
};
58

59
// Source data which can be used in SQL queries
60
export const initialState = {
11✔
61
  sourceData: {
62
    Department: department,
63
    Doctor: doctor,
64
    Patient: patient,
65
  },
66
  element: undefined,
67
  expr: {},
68
};
69

70
function getCombinedColumns(left: {[string]: any}, right: {[string]: any}) {
71
  // Combine columns adding relation name where needed
72
  const combinedColumns: Array<string> = [];
50✔
73
  for (const leftColumn of left.columns) {
50✔
74
    if (right.columns.includes(leftColumn)) {
256✔
75
      combinedColumns.push(left.name + '.' + leftColumn);
84✔
76
    } else {
77
      combinedColumns.push(leftColumn);
172✔
78
    }
79
  }
80
  for (const rightColumn of right.columns) {
50✔
81
    if (left.columns.includes(rightColumn)) {
213✔
82
      combinedColumns.push(right.name + '.' + rightColumn);
84✔
83
    } else {
84
      combinedColumns.push(rightColumn);
129✔
85
    }
86
  }
87

88
  return combinedColumns;
50✔
89
}
90

91
function getCombinedData(
92
  leftName: string,
93
  leftRow: {[string]: any},
94
  rightName: string,
95
  rightRow: {[string]: any},
96
  combinedColumns: Array<string>,
97
  outerJoin: ?boolean
98
): {[string]: any} {
99
  // Combine data from the two objects including the relation name
100
  const combinedData: {[string]: any} = {};
41,400✔
101
  for (const leftKey in leftRow) {
41,400✔
102
    combinedData[leftName + '.' + leftKey] = leftRow[leftKey];
239,721✔
103
  }
104
  for (const rightKey in rightRow) {
41,400✔
105
    if (outerJoin) {
221,687✔
106
      combinedData[rightName + '.' + rightKey] = null;
3✔
107
    } else {
108
      combinedData[rightName + '.' + rightKey] = rightRow[rightKey];
221,684✔
109
    }
110
  }
111

112
  // Resolve the output data according to the combined data
113
  // This may remove relation names where they are not needed
114
  const outputData: {[string]: any} = {};
41,400✔
115
  for (const column of combinedColumns) {
41,400✔
116
    outputData[column] = combinedData[resolveColumn(column, combinedData)];
461,408✔
117
  }
118

119
  return outputData;
41,400✔
120
}
121

122
function resolveColumn(path: string, row: {[string]: any}): string {
123
  // Avoid an error if we're projecting nothing
124
  if (!row) {
545,100!
125
    return path;
×
126
  }
127

128
  const pathParts = path.split('.');
545,100✔
129
  let [table, maybeColumn]: [?string, ?string] = [pathParts[0], pathParts[1]];
545,100✔
130
  const column: string = maybeColumn || pathParts[0];
545,100✔
131
  if (!maybeColumn) {
545,100✔
132
    table = undefined;
231,489✔
133
  }
134

135
  if (table) {
545,100✔
136
    if (row.hasOwnProperty(path)) {
313,611✔
137
      // Use the dotted path
138
      return path;
248,080✔
139
    } else if (row.hasOwnProperty(column)) {
65,531✔
140
      // Use the column name without the table qualifier
141
      return column;
40,731✔
142
    }
143
  }
144

145
  // Check for bare columns first
146
  const columns = [];
256,289✔
147
  for (const rowCol in row) {
256,289✔
148
    if (rowCol === column) {
2,936,911✔
149
      columns.push(rowCol);
232✔
150
    }
151
  }
152

153
  // Check if we found the correct column
154
  if (columns.length === 1) {
256,289✔
155
    return columns[0];
232✔
156
  } else if (columns.length > 1) {
256,057!
157
    throw new Error('Invalid column ' + path);
×
158
  }
159

160
  // Then check for the column with a prefix
161
  columns.splice(0);
256,057✔
162
  for (const rowCol in row) {
256,057✔
163
    const rowColParts = rowCol.split('.').length;
2,936,146✔
164
    if (!table && rowColParts < 3 && rowCol.endsWith('.' + column)) {
2,936,146✔
165
      columns.push(rowCol);
231,168✔
166
    }
167
  }
168

169
  // Check if we found the correct column
170
  if (columns.length === 1) {
256,057✔
171
    return columns[0];
231,168✔
172
  } else if (columns.length > 1) {
24,889!
173
    throw new Error('Invalid column ' + path);
×
174
  }
175

176
  // Finally check with a table and column prefix
177
  columns.splice(0);
24,889✔
178
  for (const rowCol in row) {
24,889✔
179
    if (table && rowCol.endsWith('.' + table + '.' + column)) {
322,610✔
180
      columns.push(rowCol);
24,800✔
181
    }
182
  }
183

184
  // Check if we found the correct column
185
  if (columns.length === 1) {
24,889✔
186
    return columns[0];
24,800✔
187
  } else {
188
    throw new Error('Invalid column ' + path);
89✔
189
  }
190
}
191

192
// Try to resolve a column, otherwise treat it as a literal
193
function resolveValue(path: string, row: {[string]: any}): string {
194
  let value = path;
83,554✔
195
  try {
83,554✔
196
    value = row[resolveColumn(path, row)];
83,554✔
197
  } catch {}
198
  return value;
83,554✔
199
}
200

201
/**
202
 * Normalizes column names for comparison by extracting the base column name
203
 * @param columnName - column name (could be qualified like "Doctor.departmentId" or unqualified like "departmentId")
204
 * @return the base column name without table qualification
205
 */
206
function normalizeColumnName(columnName: string): string {
207
  if (typeof columnName !== 'string') {
42!
208
    return columnName;
×
209
  }
210
  const parts = columnName.split('.');
42✔
211
  return parts[parts.length - 1]; // Return the last part (column name)
42✔
212
}
213

214
/**
215
 * @param expr - a relational algebra expression to evaluate
216
 * @param item - an item to evaluate against
217
 * @return result of evaluating the expression
218
 */
219
function applyItem(expr: {[string]: any}, item: {[string]: any}): any {
220
  const type = Object.keys(expr)[0];
49,325✔
221
  switch (type) {
49,325!
222
    case 'cmp':
223
      // Get the values to compare and the comparison operator
224
      const lhs = resolveValue(expr.cmp.lhs, item);
41,777✔
225
      const op = expr.cmp.op;
41,777✔
226
      let rhs = resolveValue(expr.cmp.rhs, item);
41,777✔
227

228
      switch (op) {
41,777!
229
        case '$gte':
230
          return lhs >= rhs;
10✔
231
        case '$gt':
232
          return lhs > rhs;
43✔
233
        case '$lt':
234
          return lhs < rhs;
3✔
235
        case '$lte':
236
          return lhs <= rhs;
3✔
237
        case '$ne':
238
          // eslint-disable-next-line eqeqeq
239
          return lhs != rhs;
15✔
240
        case '$eq':
241
          // eslint-disable-next-line eqeqeq
242
          return lhs == rhs;
41,703✔
243
        default:
244
          throw new Error('Invaid comparison operator');
×
245
      }
246

247
    case 'and':
248
      let andResult = true;
7,533✔
249

250
      // Loop over all expressions to be evaluated
251
      for (var i = 0; andResult && i < expr.and.clauses.length; i++) {
7,533✔
252
        andResult = andResult && applyItem(expr.and.clauses[i], item);
7,865✔
253
      }
254
      return andResult;
7,533✔
255

256
    case 'or':
257
      let orResult = false;
12✔
258

259
      // Loop over all expressions to be evaluated
260
      for (var i2 = 0; !orResult && i2 < expr.or.clauses.length; i2++) {
12✔
261
        orResult = orResult || applyItem(expr.or.clauses[i2], item);
17✔
262
      }
263
      return orResult;
12✔
264

265
    case 'not':
266
      return !applyItem(expr.not.clause, item);
3✔
267

268
    default:
269
      throw new Error('Invalid expression');
×
270
  }
271
}
272

273
/**
274
 * @param expr - a relational algebra expression to evaluate
275
 * @param sourceData - source data from relations
276
 * @return result of evaluating the expression
277
 */
278
export function applyExpr(
279
  expr: {[string]: any},
280
  sourceData: {[string]: any}
281
): {[string]: any} {
282
  const type = Object.keys(expr)[0];
220✔
283
  switch (type) {
220!
284
    case 'projection':
285
      // Evaluate the single child of this expression
286
      let projData = applyExpr(expr.projection.children[0], sourceData);
4✔
287

288
      // Get the columns which should be deleted
289
      const columns = projData.columns.map((col) =>
4✔
290
        resolveColumn(col, projData.data[0])
11✔
291
      );
292
      const keep = expr.projection.arguments.project.map((col) =>
4✔
293
        resolveColumn(col, projData.data[0])
6✔
294
      );
295
      const deleted = columns.filter((column) => keep.indexOf(column) === -1);
11✔
296

297
      // Make a copy of the list of columns to project
298
      projData.columns = keep;
4✔
299

300
      // Delete data values which should not be included
301
      for (let i = 0; i < deleted.length; i++) {
4✔
302
        for (let j = 0; j < projData.data.length; j++) {
5✔
303
          delete projData.data[j][deleted[i]];
12✔
304
        }
305
      }
306
      return projData;
4✔
307

308
    case 'selection':
309
      // Evaluate the single child of this expression
310
      let selData = applyExpr(expr.selection.children[0], sourceData);
16✔
311

312
      let select = expr.selection.arguments.select;
16✔
313
      selData.data = selData.data.filter((item) => applyItem(select, item));
48✔
314

315
      return selData;
16✔
316

317
    case 'rename':
318
      // Evaluate the single child of this expression
319
      let renData = applyExpr(expr.rename.children[0], sourceData);
1✔
320

321
      // Loop over all pairs of things to rename
322
      Object.entries(expr.rename.arguments.rename.columns).forEach(
1✔
323
        ([from, to]) => {
324
          // Ensure target name is a string
325
          if (typeof to !== 'string') {
1!
UNCOV
326
            throw new Error('Invalid target for rename');
×
327
          }
328

329
          // Add a new column with the new name
330
          const fromColumn = resolveColumn(from, renData.data[0]);
1✔
331
          renData.columns[renData.columns.indexOf(fromColumn)] = to;
1✔
332

333
          // Copy all column data and delete the original column
334
          for (let j = 0; j < renData.data.length; j++) {
1✔
335
            renData.data[j][to] = renData.data[j][fromColumn];
3✔
336
            delete renData.data[j][fromColumn];
3✔
337
          }
338
        }
339
      );
340
      return renData;
1✔
341

342
    case 'relation':
343
      // Make a copy of the data from a source table and return it
344
      return {...sourceData[expr.relation]};
128✔
345

346
    case 'order_by':
347
      let ordData = applyExpr(expr.order_by.children[0], sourceData);
3✔
348

349
      ordData.data.sort((a, b) => {
3✔
350
        let sortOrder = 0;
10✔
351
        expr.order_by.arguments.order_by.every((c) => {
10✔
352
          // Continue as long as column values are equal
353
          if (sortOrder !== 0) {
16✔
354
            return false;
4✔
355
          }
356

357
          if (a[c.column_name] < b[c.column_name]) {
12✔
358
            sortOrder = c.ascending ? -1 : 1;
5✔
359
          } else if (a[c.column_name] > b[c.column_name]) {
7✔
360
            sortOrder = c.ascending ? 1 : -1;
5✔
361
          }
362

363
          return true;
12✔
364
        });
365

366
        return sortOrder;
10✔
367
      });
368

369
      return ordData;
3✔
370

371
    case 'group_by':
372
      let groupData = applyExpr(expr.group_by.children[0], sourceData);
14✔
373
      const groupByColumns = expr.group_by.arguments.groupBy;
14✔
374
      const aggregates = expr.group_by.arguments.aggregates;
14✔
375
      const selectColumns = expr.group_by.arguments.selectColumns || [];
14!
376

377
      // Group the data by the specified columns
378
      const groups: {[string]: Array<{[string]: any}>} = {};
14✔
379
      for (const row of groupData.data) {
14✔
380
        // Create a group key from the group by columns
381
        // If no grouping columns, use a single group for all data
382
        const groupKey =
383
          groupByColumns.length > 0
70✔
384
            ? groupByColumns
385
                .map((col) => {
386
                  const resolvedCol = resolveColumn(col, row);
45✔
387
                  return row[resolvedCol];
45✔
388
                })
389
                .join('|')
390
            : 'all'; // Single group key when no GROUP BY columns
391

392
        if (!groups[groupKey]) {
70✔
393
          groups[groupKey] = [];
32✔
394
        }
395
        groups[groupKey].push(row);
70✔
396
      }
397

398
      // Calculate aggregates for each group
399
      const resultData = [];
14✔
400
      // Only include explicitly selected columns, not all GROUP BY columns
401
      const resultColumns = [...selectColumns];
14✔
402

403
      // Add aggregate columns to result columns
404
      for (const agg of aggregates) {
14✔
405
        resultColumns.push(
18✔
406
          `${agg.aggregate.function}(${agg.aggregate.column})`
407
        );
408
      }
409

410
      for (const [groupKey, groupRows] of Object.entries(groups)) {
14✔
411
        const resultRow: {[string]: any} = {};
32✔
412

413
        // Add explicitly selected column values (from SELECT clause)
414
        if (selectColumns.length > 0) {
32✔
415
          const groupKeyValues = groupKey.split('|');
21✔
416
          for (const selectCol of selectColumns) {
21✔
417
            // Find the index of this select column in the groupBy columns
418
            const groupByIndex = groupByColumns.findIndex(
21✔
419
              (groupCol) =>
420
                normalizeColumnName(groupCol) === normalizeColumnName(selectCol)
21✔
421
            );
422
            if (groupByIndex >= 0) {
21!
423
              resultRow[selectCol] = groupKeyValues[groupByIndex];
21✔
424
            }
425
          }
426
        }
427

428
        // Calculate aggregates
429
        for (const agg of aggregates) {
32✔
430
          const column = agg.aggregate.column;
44✔
431
          const func = agg.aggregate.function;
44✔
432

433
          let result;
434
          if (func === 'COUNT') {
44✔
435
            // COUNT doesn't need to resolve column, just count rows
436
            result = groupRows.length;
9✔
437
          } else {
438
            // Other aggregates need to resolve the column and get values
439
            const values = groupRows.map((row: {[string]: any}) => {
35✔
440
              const resolvedCol = resolveColumn(column, row);
75✔
441
              return parseFloat(row[resolvedCol]) || 0;
75!
442
            });
443

444
            switch (func) {
35!
445
              case 'MAX':
446
                result = Math.max(...values);
10✔
447
                break;
10✔
448
              case 'MIN':
449
                result = Math.min(...values);
13✔
450
                break;
13✔
451
              case 'AVG':
452
                result =
7✔
453
                  values.reduce((sum, val) => sum + val, 0) / values.length;
15✔
454
                break;
7✔
455
              case 'SUM':
456
                result = values.reduce((sum, val) => sum + val, 0);
15✔
457
                break;
5✔
458
              case 'STDEV':
UNCOV
459
                if (values.length <= 1) {
×
460
                  result = 0;
×
461
                } else {
462
                  const mean =
UNCOV
463
                    values.reduce((sum, val) => sum + val, 0) / values.length;
×
464
                  const variance =
UNCOV
465
                    values.reduce(
×
466
                      (sum, val) => sum + Math.pow(val - mean, 2),
×
467
                      0
468
                    ) /
469
                    (values.length - 1);
UNCOV
470
                  result = Math.sqrt(variance);
×
471
                }
UNCOV
472
                break;
×
473
              default:
UNCOV
474
                throw new Error('Unsupported aggregate function: ' + func);
×
475
            }
476
          }
477

478
          resultRow[`${func}(${column})`] = result;
44✔
479
        }
480

481
        resultData.push(resultRow);
32✔
482
      }
483

484
      return {
14✔
485
        name: groupData.name + ' (grouped)',
486
        columns: resultColumns,
487
        data: resultData,
488
      };
489

490
    case 'except':
491
    case 'intersect':
492
    case 'union':
493
      // Process each side of the operation
494
      const setLeft = applyExpr(expr[type].left, sourceData);
4✔
495
      const setRight = applyExpr(expr[type].right, sourceData);
4✔
496

497
      // Check for valid columns
498
      if (setLeft.columns.length !== setRight.columns.length) {
4!
UNCOV
499
        throw new Error(
×
500
          'Each side of ' + type + ' must have the same number of columns'
501
        );
502
      }
503

504
      const outColumns: Array<string> = setLeft.columns.slice();
4✔
505
      const setOutput: Output = {
4✔
506
        name: setLeft.name + ' ∪ ' + setRight.name,
507
        columns: outColumns,
508
        data: [],
509
      };
510

511
      for (const leftRow of setLeft.data) {
4✔
512
        // Add the row if it doesn't exist or we don't want distinct
513
        if (
8!
514
          !expr[type].distinct ||
14✔
515
          setOutput.data.find((row) => deepEqual(row, leftRow)) === undefined
3✔
516
        ) {
517
          setOutput.data.push(leftRow);
8✔
518
        }
519
      }
520

521
      // Generate new rows for the right side with the salem
522
      // column names as those on the left
523
      const newRight = setRight.data.map((rightRow) => {
4✔
524
        const newRow: {[string]: any} = {};
8✔
525
        for (const rightKey of Object.keys(rightRow)) {
8✔
526
          newRow[setLeft.columns[setRight.columns.indexOf(rightKey)]] =
8✔
527
            rightRow[rightKey];
528
        }
529

530
        return newRow;
8✔
531
      });
532

533
      if (type === 'intersect') {
4✔
534
        // Keep only rows from th left which have a match on the right
535
        setOutput.data = setOutput.data.filter((leftRow) => {
1✔
536
          for (const rightRow of newRight) {
2✔
537
            if (deepEqual(leftRow, rightRow)) {
3✔
538
              return true;
1✔
539
            }
540
          }
541
          return false;
1✔
542
        });
543
      } else {
544
        for (const rightRow of newRight) {
3✔
545
          if (type === 'except') {
6✔
546
            // Remove any matching rows
547
            setOutput.data = setOutput.data.filter(
2✔
548
              (row) => !deepEqual(row, rightRow)
3✔
549
            );
550
          } else if (type === 'union') {
4!
551
            // Add the row if it doesn't exist or we don't want distinct
552
            if (
4✔
553
              !expr[type].distinct ||
6✔
554
              setOutput.data.find((row) => deepEqual(row, rightRow)) ===
3✔
555
                undefined
556
            ) {
557
              setOutput.data.push(rightRow);
3✔
558
            }
559
          }
560
        }
561
      }
562

563
      return setOutput;
4✔
564

565
    case 'join':
566
      // Process each side of the join
567
      let joinLeft = applyExpr(expr.join.left, sourceData);
49✔
568
      let joinRight = applyExpr(expr.join.right, sourceData);
49✔
569
      const combinedJoinColumns = getCombinedColumns(joinLeft, joinRight);
49✔
570
      let joinType = expr.join.type;
49✔
571

572
      let joinSymbol = ' ⋈ ';
49✔
573
      if (joinType === 'left') {
49✔
574
        joinSymbol = ' ⟕ ';
1✔
575
      } else if (joinType === 'right') {
48✔
576
        joinSymbol = ' ⟖ ';
1✔
577
      }
578

579
      const joinOutput: Output = {
49✔
580
        name: joinLeft.name + joinSymbol + joinRight.name,
581
        columns: combinedJoinColumns,
582
        data: [],
583
      };
584

585
      if (joinType === 'right') {
49✔
586
        let temp = joinLeft;
1✔
587
        joinLeft = joinRight;
1✔
588
        joinRight = temp;
1✔
589
      }
590

591
      // Perform the join
592
      for (const leftRow of joinLeft.data) {
49✔
593
        let matchFound = false;
1,379✔
594
        for (const rightRow of joinRight.data) {
1,379✔
595
          const combinedJoinData = getCombinedData(
41,392✔
596
            joinLeft.name,
597
            leftRow,
598
            joinRight.name,
599
            rightRow,
600
            combinedJoinColumns
601
          );
602
          if (applyItem(expr.join.condition, combinedJoinData)) {
41,392✔
603
            joinOutput.data.push(combinedJoinData);
1,267✔
604
            matchFound = true;
1,267✔
605
          }
606
        }
607
        if (!matchFound && joinType !== 'inner') {
1,379✔
608
          const combinedJoinData = getCombinedData(
2✔
609
            joinLeft.name,
610
            leftRow,
611
            joinRight.name,
612
            joinRight.data[0],
613
            combinedJoinColumns,
614
            true
615
          );
616
          joinOutput.data.push(combinedJoinData);
2✔
617
        }
618
      }
619

620
      return joinOutput;
49✔
621

622
    case 'product':
623
      // Process each side of the product
624
      const left = applyExpr(expr.product.left, sourceData);
1✔
625
      const right = applyExpr(expr.product.right, sourceData);
1✔
626
      const combinedColumns = getCombinedColumns(left, right);
1✔
627

628
      const output: Output = {
1✔
629
        name: left.name + ' × ' + right.name,
630
        columns: combinedColumns,
631
        data: [],
632
      };
633

634
      // Perform the cross product
635
      for (const leftRow of left.data) {
1✔
636
        for (const rightRow of right.data) {
3✔
637
          output.data.push(
6✔
638
            getCombinedData(
639
              left.name,
640
              leftRow,
641
              right.name,
642
              rightRow,
643
              combinedColumns
644
            )
645
          );
646
        }
647
      }
648

649
      return output;
1✔
650

651
    default:
652
      // Fallback in case we get something invalid to show a nice error
UNCOV
653
      throw new Error('Invalid expression');
×
654
  }
655
}
656

657
const reducer: (State, Action) => State = produce<State, Action>(
11✔
658
  (draft: State, action: Action) => {
659
    // eslint-disable-next-line default-case
660
    switch (action.type) {
47!
661
      case RESET_EXPR:
UNCOV
662
        draft.expr = {};
×
663
        draft.current = undefined;
×
664
        draft.element = undefined;
×
665
        break;
×
666
      case CHANGE_EXPR:
667
        draft.expr = action.expr;
42✔
668
        draft.current =
42✔
669
          JSON.stringify(action.expr) === JSON.stringify({})
42!
670
            ? undefined
671
            : applyExpr(action.expr, draft.sourceData);
672
        break;
42✔
673
    }
674
  },
675
  initialState
676
);
677

678
export default reducer;
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