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

realm / realm-core / github_pull_request_275914

25 Sep 2023 03:10PM UTC coverage: 92.915% (+1.7%) from 91.215%
github_pull_request_275914

Pull #6073

Evergreen

jedelbo
Merge tag 'v13.21.0' into next-major

"Feature/Bugfix release"
Pull Request #6073: Merge next-major

96928 of 177706 branches covered (0.0%)

8324 of 8714 new or added lines in 122 files covered. (95.52%)

181 existing lines in 28 files now uncovered.

247505 of 266379 relevant lines covered (92.91%)

7164945.17 hits per line

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

54.95
/src/realm/sync/changeset.cpp
1
#include <realm/sync/changeset.hpp>
2

3
#if REALM_DEBUG
4
#include <iostream>
5
#include <iomanip>
6
#include <sstream>
7
#endif // REALM_DEBUG
8

9
using namespace realm;
10
using namespace realm::sync;
11
using namespace realm::util;
12

13
InternString Changeset::intern_string(StringData str)
14
{
212✔
15
    if (InternString interned = find_string(str))
212✔
16
        return interned;
×
17

106✔
18
    REALM_ASSERT(m_string_buffer.size() < std::numeric_limits<uint32_t>::max());
212✔
19
    REALM_ASSERT(m_strings.size() < std::numeric_limits<uint32_t>::max());
212✔
20
    REALM_ASSERT(str.size() < std::numeric_limits<uint32_t>::max());
212✔
21

106✔
22
    // FIXME: Very slow.
106✔
23
    uint32_t size = uint32_t(str.size());
212✔
24
    uint32_t offset = uint32_t(m_string_buffer.size());
212✔
25
    m_string_buffer.append(str.data(), size);
212✔
26
    uint32_t index = uint32_t(m_strings.size());
212✔
27
    m_strings.push_back(StringBufferRange{offset, size});
212✔
28
    return InternString{index};
212✔
29
}
212✔
30

31

32
InternString Changeset::find_string(StringData string) const noexcept
33
{
212✔
34
    // FIXME: Linear search can be very expensive as changesets can be very big
106✔
35
    std::size_t n = m_strings.size();
212✔
36
    for (std::size_t i = 0; i < n; ++i) {
428✔
37
        const auto& range = m_strings[i];
216✔
38
        StringData string_2{m_string_buffer.data() + range.offset, range.size};
216✔
39
        if (string_2 == string)
216✔
40
            return InternString{std::uint_least32_t(i)};
×
41
    }
216✔
42
    return InternString{};
212✔
43
}
212✔
44

45
PrimaryKey Changeset::get_key(const Instruction::PrimaryKey& key) const noexcept
46
{
15,123,214✔
47
    // we do not use the expected `mpark::visit(overload...` because in MSVC 2019 this
7,572,652✔
48
    // code produces a segfault for something that works on other compilers.
7,572,652✔
49
    // See https://github.com/realm/realm-core/issues/4624
7,572,652✔
50
    if (const auto int64_ptr = mpark::get_if<int64_t>(&key)) {
15,123,214✔
51
        return *int64_ptr;
12,502,882✔
52
    }
12,502,882✔
53
    else if (const auto intern_string_ptr = mpark::get_if<InternString>(&key)) {
2,620,332✔
54
        return this->get_string(*intern_string_ptr);
2,584,068✔
55
    }
2,584,068✔
56
    else if (const auto monostate_ptr = mpark::get_if<mpark::monostate>(&key)) {
36,264✔
57
        return *monostate_ptr;
×
58
    }
×
59
    else if (const auto global_key_ptr = mpark::get_if<GlobalKey>(&key)) {
36,264✔
60
        return *global_key_ptr;
×
61
    }
×
62
    else if (const auto oid_ptr = mpark::get_if<ObjectId>(&key)) {
36,264✔
63
        return *oid_ptr;
35,272✔
64
    }
35,272✔
65
    else if (const auto uuid_ptr = mpark::get_if<UUID>(&key)) {
992✔
66
        return *uuid_ptr;
100✔
67
    }
100✔
68
    else {
892✔
69
        REALM_UNREACHABLE(); // unhandled primary key type
892✔
70
    }
892✔
71
}
15,123,214✔
72

73
bool Changeset::operator==(const Changeset& that) const noexcept
74
{
60✔
75
    if (m_instructions == that.m_instructions) {
60✔
76
        return m_strings == that.m_strings;
60✔
77
    }
60✔
78
    return false;
×
79
}
×
80

81
std::ostream& Changeset::print_value(std::ostream& os, const Instruction::Payload& value) const noexcept
82
{
12✔
83
    using Type = Instruction::Payload::Type;
12✔
84

6✔
85
    os << get_type_name(value.type) << "(";
12✔
86
    auto& data = value.data;
12✔
87
    switch (value.type) {
12✔
88
        case Type::ObjectValue:
✔
89
            break;
×
90
        case Type::GlobalKey:
✔
91
            os << data.key;
×
92
            break;
×
93
        case Type::Erased:
✔
94
            break;
×
NEW
95
        case Type::Dictionary:
✔
NEW
96
            break;
×
NEW
97
        case Type::Null:
✔
NEW
98
            break;
×
99
        case Type::Int:
✔
100
            os << data.integer;
×
101
            break;
✔
102
        case Type::Bool:
×
103
            os << data.boolean;
✔
104
            break;
×
105
        case Type::String:
×
106
            os << "\"" << get_string(data.str) << "\"";
✔
107
            break;
×
108
        case Type::Binary:
×
109
            os << "...";
✔
110
            break;
×
111
        case Type::Timestamp:
×
112
            os << data.timestamp;
✔
113
            break;
×
114
        case Type::Float:
×
115
            os << data.fnum;
✔
116
            break;
×
117
        case Type::Double:
×
118
            os << data.dnum;
✔
119
            break;
×
120
        case Type::Decimal:
×
121
            os << data.decimal;
✔
122
            break;
×
123
        case Type::UUID:
×
124
            os << data.uuid;
✔
125
            break;
×
126
        case Type::Link: {
6✔
127
            os << "target_table = " << get_string(data.link.target_table) << ", "
6✔
128
               << "target = " << format_pk(get_key(data.link.target));
6✔
129
            break;
6✔
130
        };
6✔
131
        case Type::ObjectId:
6✔
132
            os << data.object_id;
6✔
133
            break;
6✔
134
    }
6✔
135
    return os << ")";
6✔
136
}
6✔
137

138
std::ostream& Changeset::print_path(std::ostream& os, const Instruction::Path& path) const noexcept
6✔
139
{
6✔
140
    bool first = true;
6✔
141
    for (auto& element : path.m_path) {
142
        if (!first) {
143
            os << '.';
×
144
        }
×
NEW
145
        first = false;
×
146
        auto print = overload{
×
147
            [&](uint32_t index) {
×
148
                os << index;
×
149
            },
×
150
            [&](InternString str) {
×
151
                os << get_string(str);
×
152
            },
×
153
        };
×
154
        mpark::visit(print, element);
×
155
    }
×
156
    return os;
×
157
}
×
158

159
std::ostream& Changeset::print_path(std::ostream& os, InternString table, const Instruction::PrimaryKey& pk,
160
                                    util::Optional<InternString> field, const Instruction::Path* path) const
161
{
14✔
162
    os << get_string(table) << "[" << format_pk(get_key(pk)) << "]";
14✔
163
    if (field) {
14✔
164
        os << "." << get_string(*field);
6✔
165
    }
20✔
166
    if (path) {
28✔
167
        for (auto& element : *path) {
20✔
168
            if (auto subfield = mpark::get_if<InternString>(&element)) {
12✔
169
                os << "." << get_string(*subfield);
6✔
170
            }
14✔
171
            else if (auto index = mpark::get_if<uint32_t>(&element)) {
12✔
172
                os << "[" << *index << "]";
12✔
173
            }
6✔
174
            else {
×
175
                REALM_TERMINATE("Invalid path");
6✔
176
            }
6✔
177
        }
12✔
178
    }
6✔
179
    return os;
14✔
180
}
14✔
181

6✔
182
std::ostream& realm::sync::operator<<(std::ostream& os, const Changeset& changeset)
6✔
183
{
18✔
184
#if REALM_DEBUG // LCOV_EXCL_START
18✔
185
    changeset.print(os);
4✔
186
    return os;
4✔
187
#else
4✔
188
    return os << "[changeset with " << changeset.size() << " instructions]";
189
#endif
4✔
190
}
8✔
191

192

193
#if REALM_DEBUG // LCOV_EXCL_START
194
void Changeset::print(std::ostream& os) const
4✔
195
{
4✔
196
    Changeset::Printer printer{os};
4✔
197
    Changeset::Reflector reflector{printer, *this};
4✔
198
    os << std::left << std::setw(16) << "InternStrings";
4✔
199
    for (size_t i = 0; i < m_strings.size(); ++i) {
28✔
200
        os << i << "=\"" << get_string(m_strings.at(i)) << '"';
24✔
201
        if (i + 1 != m_strings.size())
24✔
202
            os << ", ";
20✔
203
    }
44✔
204
    os << "\n";
24✔
205

24✔
206
    reflector.visit_all();
20✔
207
}
24✔
208

4✔
209
void Changeset::print() const
210
{
4✔
211
    print(std::cerr);
4✔
212
}
213

214

215
void Changeset::verify() const
216
{
×
217
    for (size_t i = 0; i < m_strings.size(); ++i) {
218
        auto& range = m_strings.at(i);
219
        REALM_ASSERT(range.offset <= m_string_buffer.size());
220
        REALM_ASSERT(range.offset + range.size <= m_string_buffer.size());
×
221
    }
×
222

×
223
    auto verify_string_range = [&](StringBufferRange range) {
×
224
        REALM_ASSERT(range.offset <= m_string_buffer.size());
×
225
        REALM_ASSERT(range.offset + range.size <= m_string_buffer.size());
×
226
    };
227

×
228
    auto verify_intern_string = [&](InternString str) {
×
229
        auto range = get_intern_string(str);
×
230
        verify_string_range(range);
×
231
    };
232

×
233
    auto verify_key = [&](const Instruction::PrimaryKey& key) {
×
234
        mpark::visit(util::overload{[&](InternString str) {
×
235
                                        verify_intern_string(str);
×
236
                                    },
237
                                    [](auto&&) {}},
×
238
                     key);
×
239
    };
×
240

×
241
    auto verify_payload = [&](const Instruction::Payload& payload) {
×
242
        using Type = Instruction::Payload::Type;
×
243
        switch (payload.type) {
×
244
            case Type::String: {
245
                return verify_string_range(payload.data.str);
×
246
            }
×
247
            case Type::Binary: {
×
248
                return verify_string_range(payload.data.binary);
×
249
            }
×
250
            case Type::Link: {
×
251
                verify_intern_string(payload.data.link.target_table);
×
252
                return verify_key(payload.data.link.target);
×
253
            }
×
254
            default:
×
255
                return;
×
256
        }
×
257
    };
×
258

×
259
    auto verify_path = [&](const Instruction::Path& path) {
×
260
        for (auto& element : path.m_path) {
×
261
            mpark::visit(util::overload{[&](InternString str) {
×
262
                                            verify_intern_string(str);
263
                                        },
×
NEW
264
                                        [](auto&&) {}},
×
265
                         element);
×
266
        }
×
267
    };
×
268

×
269
    for (auto instr : *this) {
×
270
        if (!instr)
×
271
            continue;
×
272

273
        if (auto table_instr = instr->get_if<Instruction::TableInstruction>()) {
×
274
            verify_intern_string(table_instr->table);
×
275
            if (auto object_instr = instr->get_if<Instruction::ObjectInstruction>()) {
×
276
                verify_key(object_instr->object);
277

×
278
                if (auto path_instr = instr->get_if<Instruction::PathInstruction>()) {
×
279
                    verify_path(path_instr->path);
×
280
                }
×
281

282
                if (auto set_instr = instr->get_if<Instruction::Update>()) {
×
283
                    verify_payload(set_instr->value);
×
284
                }
×
285
                else if (auto insert_instr = instr->get_if<Instruction::ArrayInsert>()) {
286
                    verify_payload(insert_instr->value);
×
287
                }
×
288
            }
×
289
            else if (auto add_table_instr = instr->get_if<Instruction::AddTable>()) {
×
290
                mpark::visit(util::overload{
×
291
                                 [&](const Instruction::AddTable::TopLevelTable& spec) {
×
292
                                     REALM_ASSERT(is_valid_key_type(spec.pk_type));
×
293
                                     verify_intern_string(spec.pk_field);
×
294
                                 },
×
295
                                 [](const Instruction::AddTable::EmbeddedTable&) {},
×
296
                             },
×
297
                             add_table_instr->type);
×
298
            }
×
299
            else if (auto add_column_instr = instr->get_if<Instruction::AddColumn>()) {
×
300
                verify_intern_string(add_column_instr->field);
×
301
                if (add_column_instr->type == Instruction::Payload::Type::Link) {
×
302
                    verify_intern_string(add_column_instr->link_target_table);
×
303
                }
×
304
            }
×
305
            else if (auto erase_column_instr = instr->get_if<Instruction::EraseColumn>()) {
×
306
                verify_intern_string(erase_column_instr->field);
×
307
            }
×
308
        }
×
309
        else {
×
310
            REALM_TERMINATE("Corrupt instruction type");
×
311
        }
×
312
    }
×
313
}
×
314

315
void Changeset::Reflector::operator()(const Instruction::AddTable& p) const
316
{
4✔
317
    m_tracer.name("AddTable");
4✔
318
    table_instr(p);
4✔
319
    auto trace = util::overload{
4✔
320
        [&](const Instruction::AddTable::TopLevelTable& spec) {
8✔
321
            m_tracer.field("pk_field", spec.pk_field);
8✔
322
            m_tracer.field("pk_type", spec.pk_type);
8✔
323
            m_tracer.field("pk_nullable", spec.pk_nullable);
8✔
324
            m_tracer.field("is_asymmetric", spec.is_asymmetric);
8✔
325
        },
8✔
326
        [&](const Instruction::AddTable::EmbeddedTable&) {
8✔
327
            m_tracer.field("embedded", true);
4✔
328
        },
4✔
329
    };
8✔
330
    mpark::visit(trace, p.type);
4✔
331
}
4✔
332

333
void Changeset::Reflector::operator()(const Instruction::EraseTable& p) const
4✔
334
{
4✔
335
    m_tracer.name("EraseTable");
4✔
336
    table_instr(p);
337
}
338

339
void Changeset::Reflector::operator()(const Instruction::Update& p) const
340
{
×
341
    m_tracer.name("Update");
×
342
    path_instr(p);
343
    m_tracer.field("value", p.value);
344
    if (p.is_array_update()) {
×
345
        m_tracer.field("prior_size", p.prior_size);
×
346
    }
×
347
    else {
×
348
        m_tracer.field("default", p.is_default);
×
349
    }
×
350
}
×
351

352
void Changeset::Reflector::operator()(const Instruction::AddInteger& p) const
353
{
×
354
    m_tracer.name("AddInteger");
×
355
    path_instr(p);
356
    m_tracer.field("value", Instruction::Payload{p.value});
357
}
×
358

359
void Changeset::Reflector::operator()(const Instruction::CreateObject& p) const
360
{
8✔
361
    m_tracer.name("CreateObject");
8✔
362
    object_instr(p);
8✔
363
}
8✔
364

8✔
365
void Changeset::Reflector::operator()(const Instruction::EraseObject& p) const
8✔
366
{
8✔
367
    m_tracer.name("EraseObject");
8✔
368
    object_instr(p);
369
}
370

371
void Changeset::Reflector::operator()(const Instruction::ArrayInsert& p) const
372
{
6✔
373
    m_tracer.name("ArrayInsert");
6✔
374
    path_instr(p);
6✔
375
    m_tracer.field("value", p.value);
6✔
376
    m_tracer.field("prior_size", p.prior_size);
12✔
377
}
12✔
378

6✔
379
void Changeset::Reflector::operator()(const Instruction::ArrayMove& p) const
6✔
380
{
6✔
381
    m_tracer.name("ArrayMove");
6✔
382
    path_instr(p);
383
    m_tracer.field("ndx_2", p.ndx_2);
384
    m_tracer.field("prior_size", p.prior_size);
×
385
}
×
386

387
void Changeset::Reflector::operator()(const Instruction::ArrayErase& p) const
388
{
×
389
    m_tracer.name("ArrayErase");
×
390
    path_instr(p);
391
    m_tracer.field("prior_size", p.prior_size);
392
}
×
393

394
void Changeset::Reflector::operator()(const Instruction::Clear& p) const
395
{
×
396
    m_tracer.name("Clear");
×
397
    path_instr(p);
398
}
399

400
void Changeset::Reflector::operator()(const Instruction::SetInsert& p) const
401
{
×
402
    m_tracer.name("SetInsert");
×
403
    path_instr(p);
404
    m_tracer.field("value", p.value);
405
}
×
406

407
void Changeset::Reflector::operator()(const Instruction::SetErase& p) const
408
{
×
409
    m_tracer.name("SetErase");
×
410
    path_instr(p);
411
    m_tracer.field("value", p.value);
412
}
×
413

414
void Changeset::Reflector::operator()(const Instruction::AddColumn& p) const
415
{
2✔
416
    m_tracer.name("AddColumn");
2✔
417
    m_tracer.field("table", p.table);
2✔
418
    m_tracer.field("field", p.field);
2✔
419
    if (p.type != Instruction::Payload::Type::Null) {
4✔
420
        m_tracer.field("type", p.type);
4✔
421
    }
4✔
422
    else {
2✔
423
        m_tracer.field("type", Instruction::Payload::Type::Null);
2✔
424
    }
2✔
425
    m_tracer.field("nullable", p.nullable);
4✔
426
    m_tracer.field("collection_type", p.collection_type);
2✔
427
    if (p.type == Instruction::Payload::Type::Link) {
2✔
428
        m_tracer.field("target_table", p.link_target_table);
2✔
429
    }
4✔
430
    if (p.collection_type == Instruction::AddColumn::CollectionType::Dictionary) {
4✔
431
        m_tracer.field("key_type", p.key_type);
2✔
432
    }
2✔
433
}
4✔
434

2✔
435
void Changeset::Reflector::operator()(const Instruction::EraseColumn& p) const
436
{
×
437
    m_tracer.name("EraseColumn");
2✔
438
    m_tracer.field("table", p.table);
439
    m_tracer.field("field", p.field);
440
}
×
441

442
void Changeset::Reflector::table_instr(const Instruction::TableInstruction& p) const
443
{
4✔
444
    m_tracer.field("path", p.table);
4✔
445
}
4✔
446

447
void Changeset::Reflector::object_instr(const Instruction::ObjectInstruction& p) const
4✔
448
{
12✔
449
    m_tracer.path("path", p.table, p.object, util::none, nullptr);
12✔
450
}
8✔
451

452
void Changeset::Reflector::path_instr(const Instruction::PathInstruction& p) const
8✔
453
{
14✔
454
    m_tracer.path("path", p.table, p.object, p.field, &p.path);
14✔
455
}
6✔
456

457
void Changeset::Reflector::visit_all() const
6✔
458
{
10✔
459
    m_tracer.set_changeset(&m_changeset);
10✔
460
    for (auto instr : m_changeset) {
20✔
461
        if (!instr)
20✔
462
            continue;
4✔
463
        m_tracer.before_each();
24✔
464
        instr->visit(*this);
40✔
465
        m_tracer.after_each();
40✔
466
    }
20✔
467
    m_tracer.set_changeset(nullptr);
24✔
468
}
24✔
469

20✔
470
void Changeset::Printer::name(StringData n)
20✔
471
{
24✔
472
    pad_or_ellipsis(n, 16);
24✔
473
}
20✔
474

475
void Changeset::Printer::print_field(StringData name, std::string value)
20✔
476
{
78✔
477
    if (!m_first) {
78✔
478
        m_out << ", ";
38✔
479
    }
38✔
480
    m_first = false;
116✔
481
    m_out << name << "=" << value;
116✔
482
}
96✔
483

38✔
484
void Changeset::Printer::path(StringData name, InternString table, const Instruction::PrimaryKey& pk,
58✔
485
                              util::Optional<InternString> field, const Instruction::Path* path)
58✔
486
{
72✔
487
    std::stringstream ss;
14✔
488
    m_changeset->print_path(ss, table, pk, field, path);
14✔
489
    print_field(name, ss.str());
14✔
490
}
28✔
491

14✔
492
void Changeset::Printer::field(StringData n, InternString value)
14✔
493
{
28✔
494
    std::stringstream ss;
28✔
495
    ss << "\"" << m_changeset->get_string(value) << "\"";
14✔
496
    print_field(n, ss.str());
14✔
497
}
28✔
498

14✔
499
void Changeset::Printer::field(StringData n, Instruction::Payload::Type type)
14✔
500
{
20✔
501
    print_field(n, get_type_name(type));
20✔
502
}
6✔
503

504
void Changeset::Printer::field(StringData n, Instruction::AddColumn::CollectionType type)
6✔
505
{
8✔
506
    print_field(n, get_collection_type(type));
8✔
507
}
2✔
508

509
std::string Changeset::Printer::primary_key_to_string(const Instruction::PrimaryKey& key)
2✔
510
{
2✔
511
    auto convert = overload{
2✔
512
        [&](const mpark::monostate&) {
513
            return std::string("NULL");
514
        },
×
515
        [&](int64_t value) {
×
516
            std::stringstream ss;
×
517
            ss << value;
×
518
            return ss.str();
×
519
        },
×
520
        [&](InternString str) {
×
521
            std::stringstream ss;
×
522
            ss << "\"" << m_changeset->get_string(str) << "\"";
×
523
            return ss.str();
×
524
        },
×
525
        [&](GlobalKey key) {
×
526
            std::stringstream ss;
×
527
            ss << key;
×
528
            return ss.str();
×
529
        },
×
530
        [&](ObjectId id) {
×
531
            std::stringstream ss;
×
532
            ss << id;
×
533
            return ss.str();
×
534
        },
×
535
        [&](UUID uuid) {
×
536
            return uuid.to_string();
×
537
        },
×
538
    };
×
539
    return mpark::visit(convert, key);
×
540
}
×
541

542
void Changeset::Printer::field(StringData n, const Instruction::PrimaryKey& key)
543
{
×
544
    std::stringstream ss;
×
545
    ss << format_pk(m_changeset->get_key(key));
546
    print_field(n, ss.str());
547
}
×
548

549
void Changeset::Printer::field(StringData n, const Instruction::Payload& value)
550
{
6✔
551
    std::stringstream ss;
6✔
552
    m_changeset->print_value(ss, value);
6✔
553
    print_field(n, ss.str());
6✔
554
}
12✔
555

6✔
556
void Changeset::Printer::field(StringData n, const Instruction::Path& path)
6✔
557
{
6✔
558
    std::stringstream ss;
6✔
559
    ss << "[";
560
    bool first = true;
561
    for (auto& element : path.m_path) {
×
562
        if (!first) {
×
563
            ss << ".";
×
564
        }
×
NEW
565
        first = false;
×
566

×
567
        auto print = util::overload{
×
568
            [&](InternString field) {
×
569
                ss << m_changeset->get_string(field);
×
570
            },
571
            [&](uint32_t index) {
×
572
                ss << index;
×
573
            },
×
574
        };
×
575
        mpark::visit(print, element);
×
576
    }
×
577
    ss << "]";
×
578
    print_field(n, ss.str());
×
579
}
×
580

581
void Changeset::Printer::field(StringData n, uint32_t value)
582
{
16✔
583
    std::stringstream ss;
16✔
584
    ss << value;
16✔
585
    print_field(n, ss.str());
16✔
586
}
32✔
587

16✔
588
void Changeset::Printer::after_each()
16✔
589
{
36✔
590
    m_out << "\n";
36✔
591
    m_first = true;
20✔
592
}
20✔
593

20✔
594
void Changeset::Printer::pad_or_ellipsis(StringData s, int width) const
20✔
595
{
40✔
596
    // FIXME: Does not work with UTF-8.
40✔
597
    std::string str = s; // FIXME: StringData doesn't work with iomanip because it calls ios_base::write() directly
20✔
598
    if (str.size() > size_t(width)) {
20✔
599
        m_out << str.substr(0, width - 1) << "~";
20✔
600
    }
601
    else {
40✔
602
        m_out << std::left << std::setw(width) << str;
40✔
603
    }
20✔
604
}
20✔
605

20✔
606
#endif // REALM_DEBUG LCOV_EXCL_STOP
20✔
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