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

mcallegari / qlcplus / 19144422256

06 Nov 2025 05:33PM UTC coverage: 34.256% (-0.1%) from 34.358%
19144422256

push

github

mcallegari
Back to 5.1.0 debug

17718 of 51723 relevant lines covered (34.26%)

19528.23 hits per line

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

74.93
/engine/src/rgbscriptv4.cpp
1
/*
2
  Q Light Controller Plus
3
  rgbscriptv4.cpp
4

5
  Copyright (c) Massimo Callegari
6

7
  Licensed under the Apache License, Version 2.0 (the "License");
8
  you may not use this file except in compliance with the License.
9
  You may obtain a copy of the License at
10

11
      http://www.apache.org/licenses/LICENSE-2.0.txt
12

13
  Unless required by applicable law or agreed to in writing, software
14
  distributed under the License is distributed on an "AS IS" BASIS,
15
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
  See the License for the specific language governing permissions and
17
  limitations under the License.
18
*/
19

20
#include <QXmlStreamReader>
21
#include <QXmlStreamWriter>
22
#include <QJSEngine>
23
#include <QThread>
24
#include <QDebug>
25
#include <QFile>
26

27
// cppcheck-suppress missingIncludeSystem
28
#include <QCoreApplication>
29
// cppcheck-suppress missingIncludeSystem
30
#include <QSemaphore>
31

32
#include "rgbscriptv4.h"
33

34
#include "rgbscriptscache.h"
35
#include "qlcconfig.h"
36
#include "qlcfile.h"
37

38
/****************************************************************************
39
 * Initialization
40
 ****************************************************************************/
41

42
JSThread* RGBScript::s_jsThread = NULL;
43

44
class JSThread: public QThread
45
{
46
public:
47
    QJSEngine *engine;
48
    QSemaphore ready;
49
    void run()
3✔
50
    {
51
        engine = new QJSEngine();
3✔
52
        ready.release(1);
3✔
53
        exec();
3✔
54
        delete engine;
3✔
55
    }
3✔
56
};
57

58

59
RGBScript::RGBScript(Doc *doc)
105✔
60
    : RGBAlgorithm(doc)
61
    , m_apiVersion(0)
105✔
62
{
63
}
105✔
64

65
RGBScript::RGBScript(const RGBScript& s)
1✔
66
    : RGBAlgorithm(s.doc())
67
    , m_fileName(s.m_fileName)
1✔
68
    , m_contents(s.m_contents)
1✔
69
    , m_apiVersion(0)
2✔
70
{
71
    evaluate();
1✔
72
    foreach (RGBScriptProperty cap, s.m_properties)
2✔
73
    {
74
        setProperty(cap.m_name, s.property(cap.m_name));
1✔
75
    }
2✔
76
}
1✔
77

78
RGBScript::~RGBScript()
127✔
79
{
80
}
127✔
81

82
RGBScript &RGBScript::operator=(const RGBScript &s)
×
83
{
84
    if (this != &s)
×
85
    {
86
        m_fileName = s.m_fileName;
×
87
        m_contents = s.m_contents;
×
88
        m_apiVersion = s.m_apiVersion;
×
89
        evaluate();
×
90
        foreach (RGBScriptProperty cap, s.m_properties)
×
91
        {
92
            setProperty(cap.m_name, s.property(cap.m_name));
×
93
        }
×
94
    }
95

96
    return *this;
×
97
}
98

99
bool RGBScript::operator==(const RGBScript& s) const
×
100
{
101
    return this->fileName().isEmpty() == false && this->fileName() == s.fileName();
×
102
}
103

104
RGBAlgorithm* RGBScript::clone() const
1✔
105
{
106
    RGBScript *script = new RGBScript(*this);
1✔
107
    return static_cast<RGBAlgorithm*> (script);
1✔
108
}
109

110
/****************************************************************************
111
 * Load & Evaluation
112
 ****************************************************************************/
113

114
bool RGBScript::load(const QString& fileName)
97✔
115
{
116
    // Create the script engine when it's first needed
117
    initEngine();
97✔
118

119
    {
120
        m_contents.clear();
97✔
121
        m_script = QJSValue();
97✔
122
        m_rgbMap = QJSValue();
97✔
123
        m_rgbMapStepCount = QJSValue();
97✔
124
        m_rgbMapSetColors = QJSValue();
97✔
125
        m_apiVersion = 0;
97✔
126
    }
127

128
    m_fileName = fileName;
97✔
129
    QFile file(m_fileName);
97✔
130
    if (file.open(QIODevice::ReadOnly) == false)
97✔
131
    {
132
        qWarning() << "Unable to load RGB script" << m_fileName;
×
133
        return false;
×
134
    }
135

136
    QTextStream stream(&file);
97✔
137
    m_contents = stream.readAll();
97✔
138
    file.close();
97✔
139

140
    return evaluate();
97✔
141
}
97✔
142

143
QString RGBScript::fileName() const
41✔
144
{
145
    return m_fileName;
41✔
146
}
147

148
void RGBScript::initEngine()
196✔
149
{
150
    if (s_jsThread == NULL)
196✔
151
    {
152
        s_jsThread = new JSThread();
3✔
153
        s_jsThread->start();
3✔
154
        // cppcheck-suppress unknownMacro
155
        qAddPostRoutine(RGBScript::cleanupEngine);
3✔
156
        s_jsThread->ready.acquire(1);
3✔
157
    }
158
    Q_ASSERT(s_jsThread->engine != NULL);
196✔
159
}
196✔
160

161
void RGBScript::cleanupEngine()
3✔
162
{
163
    s_jsThread->exit();
3✔
164
    s_jsThread->wait();
3✔
165
    delete s_jsThread;
3✔
166
    s_jsThread = NULL;
3✔
167
}
3✔
168

169

170
bool RGBScript::evaluate()
206✔
171
{
172
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
206✔
173
    {
174
        bool retVal;
175
        QMetaObject::invokeMethod(s_jsThread->engine, [this]{ return evaluate();}, Qt::BlockingQueuedConnection, &retVal);
206✔
176
        return retVal;
103✔
177
    }
178

179
    m_rgbMap = QJSValue();
103✔
180
    m_rgbMapStepCount = QJSValue();
103✔
181
    m_rgbMapSetColors = QJSValue();
103✔
182
    m_apiVersion = 0;
103✔
183

184
    if (m_fileName.isEmpty() || m_contents.isEmpty())
103✔
185
    {
186
        qWarning() << m_fileName << ": Script filename or content is empty, cannot parse";
4✔
187
        return false;
4✔
188
    }
189

190
    initEngine();
99✔
191

192
    m_script = s_jsThread->engine->evaluate(m_contents, m_fileName);
99✔
193
    if (m_script.isError())
99✔
194
    {
195
        displayError(m_script, m_fileName);
×
196
        return false;
×
197
    }
198

199
    m_rgbMap = m_script.property(QStringLiteral("rgbMap"));
198✔
200
    if (m_rgbMap.isCallable() == false)
99✔
201
    {
202
        qWarning() << m_fileName << "is missing the rgbMap() function!";
×
203
        return false;
×
204
    }
205

206
    m_rgbMapStepCount = m_script.property(QStringLiteral("rgbMapStepCount"));
198✔
207
    if (m_rgbMapStepCount.isCallable() == false)
99✔
208
    {
209
        qWarning() << m_fileName << "is missing the rgbMapStepCount() function!";
×
210
        return false;
×
211
    }
212

213
    m_apiVersion = m_script.property("apiVersion").toInt();
99✔
214
    if (m_apiVersion > 0)
99✔
215
    {
216
        if (m_apiVersion >= 3)
99✔
217
        {
218
            m_rgbMapSetColors = m_script.property(QStringLiteral("rgbMapSetColors"));
26✔
219
            if (m_rgbMapSetColors.isCallable() == false)
13✔
220
            {
221
                qWarning() << m_fileName << "is missing the rgbMapSetColors() function!";
×
222
                return false;
×
223
            }
224
        }
225
        if (m_apiVersion >= 2)
99✔
226
            return loadProperties();
81✔
227
        return true;
18✔
228
    }
229
    else
230
    {
231
        qWarning() << m_fileName << "has an invalid apiVersion:" << m_apiVersion;
×
232
        return false;
×
233
    }
234
}
235

236
void RGBScript::displayError(QJSValue e, const QString& fileName)
×
237
{
238
    if (e.isError())
×
239
    {
240
        QString msg("%1: Exception at line %2. Error: %3");
×
241
        qWarning() << msg.arg(fileName)
×
242
                         .arg(e.property("lineNumber").toInt())
×
243
                         .arg(e.toString());
×
244
        qDebug() << "Stack: " << e.property("stack").toString();
×
245
    }
×
246
}
×
247

248
/****************************************************************************
249
 * Script API
250
 ****************************************************************************/
251

252
int RGBScript::rgbMapStepCount(const QSize& size)
100✔
253
{
254
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
100✔
255
    {
256
        int retVal;
257
        QMetaObject::invokeMethod(s_jsThread->engine, [this, size]{ return rgbMapStepCount(size);}, Qt::BlockingQueuedConnection, &retVal);
100✔
258
        return retVal;
50✔
259
    }
260

261
    if (m_rgbMapStepCount.isCallable() == false)
50✔
262
        return -1;
1✔
263

264
    QJSValueList args;
49✔
265
    args << size.width() << size.height();
49✔
266
    QJSValue value = m_rgbMapStepCount.call(args);
49✔
267
    if (value.isError())
49✔
268
    {
269
        displayError(value, m_fileName);
×
270
        return -1;
×
271
    } 
272
    else 
273
    {
274
        int ret = value.isNumber() ? value.toInt() : -1;
49✔
275
        return ret;
49✔
276
    }
277
}
49✔
278

279
void RGBScript::rgbMapSetColors(const QVector<uint> &colors)
2,296✔
280
{
281
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
2,296✔
282
    {
283
        QMetaObject::invokeMethod(s_jsThread->engine, [this, colors]{ return rgbMapSetColors(colors);}, Qt::QueuedConnection);
2,296✔
284
        return;
2,181✔
285
    }
286

287
    if (m_apiVersion <= 2)
1,148✔
288
        return;
1,033✔
289

290
    if (m_rgbMap.isUndefined() == true)
115✔
291
        return;
×
292

293
    if (m_rgbMapSetColors.isCallable() == false)
115✔
294
        return;
×
295

296
    int accColors = acceptColors();
115✔
297
    int rawColorCount = colors.count();
115✔
298

299
    QJSValue jsRawColors = s_jsThread->engine->newArray(accColors);
115✔
300
    for (int i = 0; i < rawColorCount && i < accColors; i++)
301✔
301
        jsRawColors.setProperty(i, QJSValue(colors.at(i)));
186✔
302

303
    QJSValueList args;
115✔
304
    args << jsRawColors;
115✔
305

306
    QJSValue value = m_rgbMapSetColors.call(args);
115✔
307
    if (value.isError())
115✔
308
        displayError(value, m_fileName);
×
309
}
115✔
310

311
QVector<uint> RGBScript::rgbMapGetColors()
28✔
312
{
313
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
28✔
314
    {
315
        QVector<uint> retVal;
14✔
316
        QMetaObject::invokeMethod(s_jsThread->engine, [this]{ return rgbMapGetColors();}, Qt::BlockingQueuedConnection, &retVal);
28✔
317
        return retVal;
14✔
318
    }
14✔
319

320
    QVector<uint> colArray;
14✔
321

322
    if (m_rgbMap.isUndefined() == true)
14✔
323
        return colArray;
×
324

325
    QJSValue colors = m_rgbMapGetColors.call();
14✔
326
    if (!colors.isError() && colors.isArray())
14✔
327
    {
328
        QVariantList arr = colors.toVariant().toList();
×
329
        foreach (QVariant color, arr)
×
330
            colArray.append(color.toUInt());
×
331
    }
×
332

333
    return colArray;
14✔
334
}
14✔
335

336
void RGBScript::rgbMap(const QSize& size, uint rgb, int step, RGBMap &map)
2,322✔
337
{
338
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
2,322✔
339
    {
340
        QMetaObject::invokeMethod(s_jsThread->engine, [this, size, rgb, step, &map]{ rgbMap(size, rgb, step, map);}, Qt::BlockingQueuedConnection);
2,322✔
341
        return;
1,162✔
342
    }
343

344
    if (m_rgbMap.isUndefined() == true)
1,161✔
345
        return;
1✔
346

347
    // Call the rgbMap function
348
    QJSValueList args;
1,160✔
349
    args << size.width() << size.height() << rgb << step;
1,160✔
350

351
    QJSValue yarray(m_rgbMap.call(args));
1,160✔
352
    if (yarray.isError())
1,160✔
353
        displayError(yarray, m_fileName);
×
354

355
    // Check the matrix to be a valid matrix
356
    if (yarray.isArray())
1,160✔
357
    {
358
        QVariantList yvArray = yarray.toVariant().toList();
1,160✔
359
        int ylen = yvArray.length();
1,160✔
360
        map.resize(ylen);
1,160✔
361

362
        for (int y = 0; y < ylen && y < size.height(); y++)
15,634✔
363
        {
364
            QVariantList xvArray = yvArray.at(y).toList();
14,474✔
365
            int xlen = xvArray.length();
14,474✔
366
            map[y].resize(xlen);
14,474✔
367

368
            for (int x = 0; x < xlen && x < size.width(); x++)
133,586✔
369
                map[y][x] = xvArray.at(x).toUInt();
119,112✔
370
        }
14,474✔
371
    }
1,160✔
372
    else
373
    {
374
        qWarning() << "Returned value is not an array within an array!";
×
375
    }
376
}
1,160✔
377

378
QString RGBScript::name() const
110✔
379
{
380
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
110✔
381
    {
382
        QString retVal;
55✔
383
        QMetaObject::invokeMethod(s_jsThread->engine, [this]{ return name();}, Qt::BlockingQueuedConnection, &retVal);
110✔
384
        return retVal;
55✔
385
    }
55✔
386

387
    QJSValue name = m_script.property(QStringLiteral("name"));
165✔
388
    QString ret = name.isUndefined() ? QString() : name.toString();
55✔
389
    return ret;
55✔
390
}
55✔
391

392
QString RGBScript::author() const
82✔
393
{
394
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
82✔
395
    {
396
        QString retVal;
41✔
397
        QMetaObject::invokeMethod(s_jsThread->engine, [this]{ return author();}, Qt::BlockingQueuedConnection, &retVal);
82✔
398
        return retVal;
41✔
399
    }
41✔
400

401
    QJSValue author = m_script.property(QStringLiteral("author"));
123✔
402
    QString ret = author.isUndefined() ? QString() : author.toString();
41✔
403
    return ret;
41✔
404
}
41✔
405

406
int RGBScript::apiVersion() const
186✔
407
{
408
    return m_apiVersion;
186✔
409
}
410

411
RGBAlgorithm::Type RGBScript::type() const
58✔
412
{
413
    return RGBAlgorithm::Script;
58✔
414
}
415

416
int RGBScript::acceptColors() const
94,389✔
417
{
418
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
94,389✔
419
    {
420
        int retVal;
421
        QMetaObject::invokeMethod(s_jsThread->engine, [this]{ return acceptColors();}, Qt::BlockingQueuedConnection, &retVal);
94,274✔
422
        return retVal;
47,137✔
423
    }
424

425
    QJSValue accColors = m_script.property(QStringLiteral("acceptColors"));
141,756✔
426
    if (!accColors.isUndefined())
47,252✔
427
        return accColors.toInt();
25,527✔
428
    // if no property is provided, let's assume the script
429
    // will accept both start and end colors
430
    return 2;
21,725✔
431
}
47,252✔
432

433
bool RGBScript::loadXML(QXmlStreamReader &root)
×
434
{
435
    Q_UNUSED(root)
436

437
    return false;
×
438
}
439

440
bool RGBScript::saveXML(QXmlStreamWriter *doc) const
1✔
441
{
442
    Q_ASSERT(doc != NULL);
1✔
443

444
    if (apiVersion() > 0 && name().isEmpty() == false)
1✔
445
    {
446
        doc->writeStartElement(KXMLQLCRGBAlgorithm);
2✔
447
        doc->writeAttribute(KXMLQLCRGBAlgorithmType, KXMLQLCRGBScript);
3✔
448
        doc->writeCharacters(name());
1✔
449
        doc->writeEndElement();
1✔
450
        return true;
1✔
451
    }
452
    else
453
    {
454
        return false;
×
455
    }
456
}
457

458
/************************************************************************
459
 * Capabilities
460
 ************************************************************************/
461

462
QList<RGBScriptProperty> RGBScript::properties()
106✔
463
{
464
    return m_properties;
106✔
465
}
466

467
QHash<QString, QString> RGBScript::propertiesAsStrings()
×
468
{
469
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
×
470
    {
471
        QHash<QString, QString> retVal;
×
472
        QMetaObject::invokeMethod(s_jsThread->engine, [this]{ return propertiesAsStrings();}, Qt::BlockingQueuedConnection, &retVal);
×
473
        return retVal;
×
474
    }
×
475

476
    QHash<QString, QString> properties;
×
477
    foreach (RGBScriptProperty cap, m_properties)
×
478
    {
479
        QJSValue readMethod = m_script.property(cap.m_readMethod);
×
480
        if (readMethod.isCallable())
×
481
        {
482
            QJSValueList args;
×
483
            QJSValue value = readMethod.call(args);
×
484
            if (value.isError())
×
485
                displayError(value, m_fileName);
×
486
            else if (!value.isUndefined())
×
487
                properties.insert(cap.m_name, value.toString());
×
488
        }
×
489
    }
×
490
    return properties;
×
491
}
×
492

493
bool RGBScript::setProperty(QString propertyName, QString value)
452✔
494
{
495
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
452✔
496
    {
497
        bool retVal;
498
        QMetaObject::invokeMethod(s_jsThread->engine, [this, propertyName, value]{ return setProperty(propertyName, value);}, Qt::BlockingQueuedConnection, &retVal);
452✔
499
        return retVal;
226✔
500
    }
501

502
    foreach (RGBScriptProperty cap, m_properties)
675✔
503
    {
504
        if (cap.m_name == propertyName)
675✔
505
        {
506
            QJSValue writeMethod = m_script.property(cap.m_writeMethod);
226✔
507
            if (writeMethod.isCallable() == false)
226✔
508
            {
509
                qWarning() << name() << "doesn't have a write function for" << propertyName;
×
510
                return false;
×
511
            }
512
            QJSValueList args;
226✔
513
            args << value;
226✔
514
            QJSValue written = writeMethod.call(args);
226✔
515
            if (written.isError())
226✔
516
            {
517
                displayError(written, m_fileName);
×
518
                return false;
×
519
            } 
520
            else 
521
            {
522
                return true;
226✔
523
            }
524
        }
226✔
525
    }
901✔
526
    return false;
×
527
}
528

529
QString RGBScript::property(QString propertyName) const
1,184✔
530
{
531
    if (s_jsThread != NULL && QThread::currentThread() != s_jsThread)
1,184✔
532
    {
533
        QString retVal;
592✔
534
        QMetaObject::invokeMethod(s_jsThread->engine, [this, propertyName]{ return property(propertyName);}, Qt::BlockingQueuedConnection, &retVal);
1,184✔
535
        return retVal;
592✔
536
    }
592✔
537

538
    foreach (RGBScriptProperty cap, m_properties)
1,716✔
539
    {
540
        if (cap.m_name == propertyName)
1,715✔
541
        {
542
            QJSValue readMethod = m_script.property(cap.m_readMethod);
591✔
543
            if (readMethod.isCallable() == false)
591✔
544
            {
545
                qWarning() << name() << "doesn't have a read function for" << propertyName;
×
546
                return QString();
×
547
            }
548
            QJSValueList args;
591✔
549
            QJSValue value = readMethod.call(args);
591✔
550
            if (value.isError())
591✔
551
            {
552
                displayError(value, m_fileName);
×
553
                return QString();
×
554
            } 
555
            else if (!value.isUndefined())
591✔
556
            {
557
                return value.toString();
591✔
558
            }
559
            else
560
            {
561
                return QString();
×
562
            }
563
        }
591✔
564
    }
2,307✔
565
    return QString();
1✔
566
}
567

568
bool RGBScript::loadProperties()
111✔
569
{
570
    QJSValue svCaps = m_script.property(QStringLiteral("properties"));
333✔
571
    if (svCaps.isArray() == false)
111✔
572
    {
573
        qWarning() << m_fileName << "properties is not an array!";
×
574
        return false;
×
575
    }
576
    QVariant varCaps = svCaps.toVariant();
111✔
577
    if (varCaps.isValid() == false)
111✔
578
    {
579
        qWarning() << m_fileName << "has invalid properties!";
×
580
        return false;
×
581
    }
582

583
    m_properties.clear();
111✔
584

585
    QStringList slCaps = varCaps.toStringList();
111✔
586
    foreach (QString cap, slCaps)
369✔
587
    {
588
        RGBScriptProperty newCap;
258✔
589

590
        QStringList propsList = cap.split('|');
258✔
591
        foreach (QString prop, propsList)
1,806✔
592
        {
593
            QStringList keyValue = prop.split(':');
1,548✔
594
            if (keyValue.length() < 2)
1,548✔
595
            {
596
                qWarning() << prop << ": malformed property. Please fix it.";
×
597
                continue;
×
598
            }
599
            QString key = keyValue.at(0).simplified();
1,548✔
600
            QString value = keyValue.at(1);
1,548✔
601
            if (key == QStringLiteral("name"))
1,548✔
602
            {
603
                newCap.m_name = value;
258✔
604
            }
605
            else if (key == QStringLiteral("type"))
1,290✔
606
            {
607
                if (value == "list") newCap.m_type = RGBScriptProperty::List;
258✔
608
                else if (value == "float") newCap.m_type = RGBScriptProperty::Float;
105✔
609
                else if (value == "range") newCap.m_type = RGBScriptProperty::Range;
105✔
610
                else if (value == "string") newCap.m_type = RGBScriptProperty::String;
×
611
            }
612
            else if (key == QStringLiteral("display"))
1,032✔
613
            {
614
                newCap.m_displayName = value.simplified();
258✔
615
            }
616
            else if (key == QStringLiteral("values"))
774✔
617
            {
618
                QStringList values = value.split(",");
258✔
619
                switch(newCap.m_type)
258✔
620
                {
621
                    case RGBScriptProperty::List:
153✔
622
                        newCap.m_listValues = values;
153✔
623
                    break;
153✔
624
                    case RGBScriptProperty::Range:
105✔
625
                    {
626
                        if (values.length() < 2)
105✔
627
                        {
628
                            qWarning() << value << ": malformed property. A range should be defined as 'min,max'. Please fix it.";
×
629
                        }
630
                        else
631
                        {
632
                            newCap.m_rangeMinValue = values.at(0).toInt();
105✔
633
                            newCap.m_rangeMaxValue = values.at(1).toInt();
105✔
634
                        }
635
                    }
636
                    break;
105✔
637
                    default:
×
638
                        qWarning() << value << ": values cannot be applied before the 'type' property or on type:integer and type:string";
×
639
                    break;
×
640
                }
641
            }
258✔
642
            else if (key == QStringLiteral("write"))
516✔
643
            {
644
                newCap.m_writeMethod = value.simplified();
258✔
645
            }
646
            else if (key == QStringLiteral("read"))
258✔
647
            {
648
                newCap.m_readMethod = value.simplified();
258✔
649
            }
650
            else
651
            {
652
                qWarning() << value << ": unknown property!";
×
653
            }
654
        }
3,354✔
655

656
        if (newCap.m_name.isEmpty() == false &&
516✔
657
            newCap.m_type != RGBScriptProperty::None)
258✔
658
                m_properties.append(newCap);
258✔
659
    }
369✔
660

661
    return true;
111✔
662
}
111✔
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