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

mcallegari / qlcplus / 13565089808

27 Feb 2025 11:14AM UTC coverage: 31.494% (-0.007%) from 31.501%
13565089808

Pull #1687

github

web-flow
Merge 918a6afc2 into 8759720d5
Pull Request #1687: scripts: fix QT 6 builds

0 of 31 new or added lines in 1 file covered. (0.0%)

4 existing lines in 1 file now uncovered.

14154 of 44942 relevant lines covered (31.49%)

26961.13 hits per line

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

0.0
/ui/src/scripteditor.cpp
1
/*
2
  Q Light Controller
3
  scripteditor.cpp
4

5
  Copyright (C) Heikki Junnila
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 <QTextDocument>
21
#include <QInputDialog>
22
#include <QFileDialog>
23
#include <QTextCursor>
24
#include <QMessageBox>
25
#include <QFormLayout>
26
#include <QFileInfo>
27
#include <QAction>
28
#include <QDebug>
29
#include <QMenu>
30
#include <cmath>
31

32
#include "functionselection.h"
33
#include "channelsselection.h"
34
#include "assignhotkey.h"
35
#include "scripteditor.h"
36
#include "mastertimer.h"
37
#include "speeddial.h"
38
#include "scriptwrapper.h"
39
#include "doc.h"
40

41
ScriptEditor::ScriptEditor(QWidget* parent, Script* script, Doc* doc)
×
42
    : QWidget(parent)
43
    , m_script(script)
44
    , m_doc(doc)
45
    , m_lastUsedPath(QString())
×
46
{
47
    setupUi(this);
×
48
    initAddMenu();
×
49

50
    /* Name */
51
    m_nameEdit->setText(m_script->name());
×
52
    m_nameEdit->setSelection(0, m_nameEdit->text().length());
×
53
    connect(m_nameEdit, SIGNAL(textEdited(const QString&)),
×
54
            this, SLOT(slotNameEdited(const QString&)));
55

56
    /* Document */
57
    m_document = new QTextDocument(m_script->data(), this);
×
58
#if (QT_VERSION < QT_VERSION_CHECK(5, 10, 0))
59
    m_editor->setTabStopWidth(20);
60
#else
61
    m_editor->setTabStopDistance(20);
×
62
#endif
63
    m_editor->setDocument(m_document);
×
64
    connect(m_document, SIGNAL(undoAvailable(bool)), m_undoButton, SLOT(setEnabled(bool)));
×
65
    m_document->setUndoRedoEnabled(false);
×
66
    m_document->setUndoRedoEnabled(true);
×
67
    m_document->clearUndoRedoStacks();
×
68

69
    m_editor->moveCursor(QTextCursor::End);
×
70
    connect(m_document, SIGNAL(contentsChanged()), this, SLOT(slotContentsChanged()));
×
71

72
    connect(m_testPlayButton, SIGNAL(clicked()), this, SLOT(slotTestRun()));
×
73
    connect(m_checkButton, SIGNAL(clicked()), this, SLOT(slotCheckSyntax()));
×
74

75
    connect(m_script, SIGNAL(stopped(quint32)), this, SLOT(slotFunctionStopped(quint32)));
×
76

77
    // Set focus to the editor
78
    m_nameEdit->setFocus();
×
79
}
×
80

81
ScriptEditor::~ScriptEditor()
×
82
{
83
    delete m_document;
×
84
    m_document = NULL;
×
85

86
    if (m_testPlayButton->isChecked() == true)
×
87
        m_script->stopAndWait();
×
88
}
×
89

90
void ScriptEditor::initAddMenu()
×
91
{
92
    m_addStartFunctionAction = new QAction(QIcon(":/function.png"), tr("Start Function"), this);
×
93
    connect(m_addStartFunctionAction, SIGNAL(triggered(bool)),
×
94
            this, SLOT(slotAddStartFunction()));
95

96
    m_addStopFunctionAction = new QAction(QIcon(":/fileclose.png"), tr("Stop Function"), this);
×
97
    connect(m_addStopFunctionAction, SIGNAL(triggered(bool)),
×
98
            this, SLOT(slotAddStopFunction()));
99

100
    m_addBlackoutAction = new QAction(QIcon(":/blackout.png"), tr("Blackout"), this);
×
101
    connect(m_addBlackoutAction, SIGNAL(triggered(bool)),
×
102
            this, SLOT(slotAddBlackout()));
103

104
    m_addWaitAction = new QAction(QIcon(":/speed.png"), tr("Wait"), this);
×
105
    connect(m_addWaitAction, SIGNAL(triggered(bool)),
×
106
            this, SLOT(slotAddWait()));
107

108
    m_addWaitKeyAction = new QAction(QIcon(":/key_bindings.png"), tr("Wait Key"), this);
×
109
    connect(m_addWaitKeyAction, SIGNAL(triggered(bool)),
×
110
            this, SLOT(slotAddWaitKey()));
111

112
    m_addSetHtpAction = new QAction(QIcon(":/fixture.png"), tr("Set HTP"), this);
×
113
    connect(m_addSetHtpAction, SIGNAL(triggered(bool)),
×
114
            this, SLOT(slotAddSetHtp()));
115

116
    m_addSetLtpAction = new QAction(QIcon(":/fixture.png"), tr("Set LTP"), this);
×
117
    connect(m_addSetLtpAction, SIGNAL(triggered(bool)),
×
118
            this, SLOT(slotAddSetLtp()));
119

120
    m_addSetFixtureAction = new QAction(QIcon(":/movinghead.png"), tr("Set Fixture"), this);
×
121
    connect(m_addSetFixtureAction, SIGNAL(triggered(bool)),
×
122
            this, SLOT(slotAddSetFixture()));
123

124
    m_addSystemCommandAction = new QAction(QIcon(":/player_play.png"), tr("System Command"), this);
×
125
    connect(m_addSystemCommandAction, SIGNAL(triggered(bool)),
×
126
            this, SLOT(slotAddSystemCommand()));
127

128
    m_addCommentAction = new QAction(QIcon(":/label.png"), tr("Comment"), this);
×
129
    connect(m_addCommentAction, SIGNAL(triggered(bool)),
×
130
            this, SLOT(slotAddComment()));
131

132
    m_addRandomAction = new QAction(QIcon(":/other.png"), tr("Random Number"), this);
×
133
    connect(m_addRandomAction, SIGNAL(triggered(bool)),
×
134
            this, SLOT(slotAddRandom()));
135

136
    m_addFilePathAction = new QAction(QIcon(":/fileopen.png"), tr("File Path"), this);
×
137
    connect(m_addFilePathAction, SIGNAL(triggered(bool)),
×
138
            this, SLOT(slotAddFilePath()));
139

140
    m_addMenu = new QMenu(this);
×
141
    m_addMenu->addAction(m_addStartFunctionAction);
×
142
    m_addMenu->addAction(m_addStopFunctionAction);
×
143
    m_addMenu->addAction(m_addBlackoutAction);
×
144
    //m_addMenu->addAction(m_addSetHtpAction);
145
    //m_addMenu->addAction(m_addSetLtpAction);
146
    m_addMenu->addAction(m_addSetFixtureAction);
×
147
    m_addMenu->addAction(m_addSystemCommandAction);
×
148
    m_addMenu->addSeparator();
×
149
    m_addMenu->addAction(m_addWaitAction);
×
150
    //m_addMenu->addAction(m_addWaitKeyAction);
151
    m_addMenu->addSeparator();
×
152
    m_addMenu->addAction(m_addCommentAction);
×
153
    m_addMenu->addAction(m_addRandomAction);
×
154
    m_addMenu->addAction(m_addFilePathAction);
×
155

156
    m_addButton->setMenu(m_addMenu);
×
157
}
×
158

159
QString ScriptEditor::getFilePath()
×
160
{
161
    /* Create a file open dialog */
162
    QFileDialog dialog(this);
×
163
    dialog.setWindowTitle(tr("Open Executable File"));
×
164
    dialog.setAcceptMode(QFileDialog::AcceptOpen);
×
165

166
    QStringList filters;
167
#if defined(WIN32) || defined(Q_OS_WIN)
168
    filters << tr("All Files (*.*)");
169
#else
170
    filters << tr("All Files (*)");
×
171
#endif
172
    dialog.setNameFilters(filters);
×
173

174
    /* Append useful URLs to the dialog */
175
    QList <QUrl> sidebar;
×
176
    sidebar.append(QUrl::fromLocalFile(QDir::homePath()));
×
177
    sidebar.append(QUrl::fromLocalFile(QDir::rootPath()));
×
178
    dialog.setSidebarUrls(sidebar);
×
179

180
    if (!m_lastUsedPath.isEmpty())
×
181
        dialog.setDirectory(m_lastUsedPath);
×
182

183
    /* Get file name */
184
    if (dialog.exec() != QDialog::Accepted)
×
185
        return QString();
186

187
    QString fn = dialog.selectedFiles().first();
×
188
    if (fn.isEmpty() == true)
×
189
        return QString();
190

191
#if defined(WIN32) || defined(Q_OS_WIN)
192
    fn.replace("/", "\\");
193
#endif
194

195
    if (fn.contains(" "))
×
196
        return QString("\"%1\"").arg(fn);
×
197
    else
198
        return fn;
199
}
200

201
void ScriptEditor::slotNameEdited(const QString& name)
×
202
{
203
    m_script->setName(name);
×
204
}
×
205

206
void ScriptEditor::slotContentsChanged()
×
207
{
208
    //! @todo: this might become quite heavy if there's a lot of content
209
    m_script->setData(m_document->toPlainText());
×
210
    m_doc->setModified();
×
211
}
×
212

213
void ScriptEditor::slotFunctionStopped(quint32 id)
×
214
{
215
    if (id == m_script->id())
×
216
    {
217
        m_testPlayButton->blockSignals(true);
×
218
        m_testPlayButton->setChecked(false);
×
219
        m_testPlayButton->blockSignals(false);
×
220
    }
221
}
×
222

223
void ScriptEditor::slotAddStartFunction()
×
224
{
225
    FunctionSelection fs(this, m_doc);
×
226
    fs.setDisabledFunctions(QList <quint32> () << m_script->id());
×
227
    if (fs.exec() == QDialog::Accepted)
×
228
    {
229
        m_editor->moveCursor(QTextCursor::StartOfLine);
×
230
        QTextCursor cursor(m_editor->textCursor());
×
231

232
        foreach (quint32 id, fs.selection())
×
233
        {
234
            Function* function = m_doc->function(id);
×
235
            Q_ASSERT(function != NULL);
236
            QString cmd =
237
                #ifdef QT_QML_LIB
238
                    QString("%1(%2); // %3\n")
239
                #else
NEW
240
                    QString("%1:%2 // %3\n")
×
241
                #endif
NEW
242
                .arg(Script::startFunctionCmd)
×
NEW
243
                .arg(id)
×
NEW
244
                .arg(function->name());
×
245
            cursor.insertText(cmd);
×
246
            m_editor->moveCursor(QTextCursor::Down);
×
247
        }
248
    }
249
}
×
250

251
void ScriptEditor::slotAddStopFunction()
×
252
{
253
    FunctionSelection fs(this, m_doc);
×
254
    fs.setDisabledFunctions(QList <quint32> () << m_script->id());
×
255
    if (fs.exec() == QDialog::Accepted)
×
256
    {
257
        m_editor->moveCursor(QTextCursor::StartOfLine);
×
258
        QTextCursor cursor(m_editor->textCursor());
×
259

260
        foreach (quint32 id, fs.selection())
×
261
        {
262
            Function* function = m_doc->function(id);
×
263
            Q_ASSERT(function != NULL);
264
            QString cmd =
265
                #ifdef QT_QML_LIB
266
                    QString("%1(%2); // %3\n")
267
                #else
NEW
268
                    QString("%1:%2 // %3\n")
×
269
                #endif
NEW
270
                .arg(Script::stopFunctionCmd)
×
NEW
271
                .arg(id)
×
NEW
272
                .arg(function->name());
×
273
            cursor.insertText(cmd);
×
274
            m_editor->moveCursor(QTextCursor::Down);
×
275
        }
276
    }
277
}
×
278

279
void ScriptEditor::slotAddBlackout()
×
280
{
281
    QDialog dialog(this);
×
282
    // Use a layout allowing to have a label next to each field
283
    QVBoxLayout dLayout(&dialog);
×
284

285
    QCheckBox *cb = new QCheckBox(tr("Blackout state"));
×
286
    cb->setChecked(true);
×
287
    dLayout.addWidget(cb);
×
288

289
    // Add some standard buttons (Cancel/Ok) at the bottom of the dialog
290
    QDialogButtonBox buttonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel,
291
                               Qt::Horizontal, &dialog);
×
292
    dLayout.addWidget(&buttonBox);
×
293
    QObject::connect(&buttonBox, SIGNAL(accepted()), &dialog, SLOT(accept()));
×
294
    QObject::connect(&buttonBox, SIGNAL(rejected()), &dialog, SLOT(reject()));
×
295

296
    // Show the dialog as modal
297
    if (dialog.exec() == QDialog::Accepted)
×
298
    {
299
        m_editor->moveCursor(QTextCursor::StartOfLine);
×
NEW
300
        m_editor->textCursor().insertText(
×
301
            #ifdef QT_QML_LIB
302
                QString("%1(%2);\n").arg(Script::blackoutCmd)
303
                                    .arg(cb->isChecked() ? "true" : "false")
304
            #else
NEW
305
                QString("%1:%2\n").arg(Script::blackoutCmd)
×
NEW
306
                                  .arg(cb->isChecked() ? Script::blackoutOn : Script::blackoutOff)
×
307
            #endif
308
        );
309
    }
310
}
×
311

312
void ScriptEditor::slotAddWait()
×
313
{
314
    QDialog dialog(this);
×
315
    // Use a layout allowing to have a label next to each field
316
    QVBoxLayout dLayout(&dialog);
×
317

318
    dLayout.addWidget(new QLabel(tr("Enter the desired time")));
×
319
    SpeedDial *sd = new SpeedDial(this);
×
320
    ushort dialMask = sd->visibilityMask();
×
321
    dialMask = (dialMask & ~SpeedDial::Infinite);
322
    dialMask = (dialMask & ~SpeedDial::Tap);
×
323
    sd->setVisibilityMask(dialMask);
×
324
    sd->setValue(1000);
×
325
    dLayout.addWidget(sd);
×
326

327
    // Add some standard buttons (Cancel/Ok) at the bottom of the dialog
328
    QDialogButtonBox buttonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel,
329
                               Qt::Horizontal, &dialog);
×
330
    dLayout.addWidget(&buttonBox);
×
331
    QObject::connect(&buttonBox, SIGNAL(accepted()), &dialog, SLOT(accept()));
×
332
    QObject::connect(&buttonBox, SIGNAL(rejected()), &dialog, SLOT(reject()));
×
333

334
    // Show the dialog as modal
335
    if (dialog.exec() == QDialog::Accepted)
×
336
    {
337
        m_editor->moveCursor(QTextCursor::StartOfLine);
×
NEW
338
        m_editor->textCursor().insertText(
×
339
            #ifdef QT_QML_LIB
340
                QString("%1(\"%2\");\n")
341
            #else
NEW
342
                QString("%1:%2\n")
×
343
            #endif
NEW
344
                .arg(Script::waitCmd)
×
NEW
345
                .arg(Function::speedToString(sd->value())));
×
346
    }
347
}
×
348

349
void ScriptEditor::slotAddWaitKey()
×
350
{
351
    AssignHotKey ahk(this);
×
352
    if (ahk.exec() == QDialog::Accepted)
×
353
    {
354
        m_editor->moveCursor(QTextCursor::StartOfLine);
×
355
        m_editor->textCursor().insertText(QString("%1:%2 // Not supported yet\n")
×
356
                                                    .arg(Script::waitKeyCmd)
×
357
                                                    .arg(ahk.keySequence().toString()));
×
358
    }
359
}
×
360

361
void ScriptEditor::slotAddSetHtp()
×
362
{
363
    m_editor->moveCursor(QTextCursor::StartOfLine);
×
364
    m_editor->textCursor().insertText(QString("sethtp:0 val:0 uni:1 // Not supported yet\n"));
×
365
    m_editor->moveCursor(QTextCursor::EndOfLine);
×
366
}
×
367

368
void ScriptEditor::slotAddSetLtp()
×
369
{
370
    m_editor->moveCursor(QTextCursor::StartOfLine);
×
371
    m_editor->textCursor().insertText(QString("setltp:0 val:0 uni:1 // Not supported yet\n"));
×
372
    m_editor->moveCursor(QTextCursor::EndOfLine);
×
373
}
×
374

375
void ScriptEditor::slotAddSetFixture()
×
376
{
377
    ChannelsSelection cfg(m_doc, this);
×
378
    if (cfg.exec() == QDialog::Rejected)
×
379
        return; // User pressed cancel
×
380

381
    QList<SceneValue> channelsList = cfg.channelsList();
×
382
    foreach (SceneValue sv, channelsList)
×
383
    {
384
        Fixture* fxi = m_doc->fixture(sv.fxi);
×
385
        if (fxi != NULL)
×
386
        {
387
            const QLCChannel* channel = fxi->channel(sv.channel);
×
388
            m_editor->moveCursor(QTextCursor::StartOfLine);
×
NEW
389
            m_editor->textCursor().insertText(
×
390
            #ifdef QT_QML_LIB
391
                QString("%1(%2,%3,0); // %4, %5\n")
392
            #else
NEW
393
                QString("%1:%2 ch:%3 val:0 // %4, %5\n")
×
394
            #endif
NEW
395
                .arg(Script::setFixtureCmd)
×
NEW
396
                .arg(fxi->id()).arg(sv.channel)
×
NEW
397
                .arg(fxi->name()).arg(channel->name()));
×
UNCOV
398
            m_editor->moveCursor(QTextCursor::Down);
×
399
        }
400
    }
401
}
402

403
void ScriptEditor::slotAddSystemCommand()
×
404
{
405
    QString fn = getFilePath();
×
406
    if (fn.isEmpty())
×
407
        return;
×
408

409
    QFileInfo fInfo(fn);
×
410
#if !defined(WIN32) && !defined(Q_OS_WIN)
411
    if (fInfo.isExecutable() == false)
×
412
    {
413
        QMessageBox::warning(this, tr("Invalid executable"), tr("Please select an executable file!"));
×
414
        return;
×
415
    }
416
#endif
417
    m_lastUsedPath = fInfo.absolutePath();
×
418

419
    QString args = QInputDialog::getText(this, tr("Enter the program arguments (leave empty if not required)"), "",
×
420
                                        QLineEdit::Normal, QString());
×
421

422
    QStringList argsList = args.split(" ");
×
423
    QString formattedArgs;
×
424
    foreach (QString arg, argsList)
×
425
    {
426
        formattedArgs.append(
427
            #ifdef QT_QML_LIB
428
                QString("%1 ")
429
            #else
NEW
430
                QString("arg:%1 ")
×
431
            #endif
NEW
432
        .arg(arg));
×
433
    }
NEW
434
    if (formattedArgs.endsWith(' '))
×
NEW
435
        formattedArgs = formattedArgs.left(formattedArgs.length()-1);
×
436

437
    m_editor->moveCursor(QTextCursor::StartOfLine);
×
NEW
438
    m_editor->textCursor().insertText(
×
439
        #ifdef QT_QML_LIB
440
            QString("%1(\"%2 %3\");\n")
441
        #else
NEW
442
            QString("%1:%2 %3\n")
×
443
        #endif
NEW
444
            .arg(Script::systemCmd)
×
NEW
445
            .arg(fn).arg(formattedArgs));
×
UNCOV
446
    m_editor->moveCursor(QTextCursor::Down);
×
447
}
448

449
void ScriptEditor::slotAddComment()
×
450
{
451
    bool ok = false;
×
452
    QString str = QInputDialog::getText(this, tr("Add Comment"), "",
×
453
                                        QLineEdit::Normal, QString(), &ok);
×
454
    if (ok == true)
×
455
    {
456
        //m_editor->moveCursor(QTextCursor::StartOfLine);
457
        m_editor->textCursor().insertText(QString("// %1").arg(str));
×
458
        //m_editor->moveCursor(QTextCursor::EndOfLine);
459
    }
460
}
×
461

462
void ScriptEditor::slotAddRandom()
×
463
{
464
    QDialog dialog(this);
×
465
    // Use a layout allowing to have a label next to each field
466
    QFormLayout dLayout(&dialog);
×
467

468
    dLayout.addRow(new QLabel(tr("Enter the range for the randomization")));
×
469

470
    QSpinBox *minSB = new QSpinBox(this);
×
471
    minSB->setRange(0, 999);
×
472
    QSpinBox *maxSB = new QSpinBox(this);
×
473
    maxSB->setRange(0, 999);
×
474
    maxSB->setValue(255);
×
475
    dLayout.addRow(tr("Minimum value"), minSB);
×
476
    dLayout.addRow(tr("Maximum value"), maxSB);
×
477

478
    // Add some standard buttons (Cancel/Ok) at the bottom of the dialog
479
    QDialogButtonBox buttonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel,
480
                               Qt::Horizontal, &dialog);
×
481
    dLayout.addRow(&buttonBox);
×
482
    QObject::connect(&buttonBox, SIGNAL(accepted()), &dialog, SLOT(accept()));
×
483
    QObject::connect(&buttonBox, SIGNAL(rejected()), &dialog, SLOT(reject()));
×
484

485
    // Show the dialog as modal
486
    if (dialog.exec() == QDialog::Accepted)
×
487
    {
488
        m_editor->moveCursor(QTextCursor::StartOfLine);
×
NEW
489
        m_editor->textCursor().insertText(
×
490
            #ifdef QT_QML_LIB
491
                QString("Engine.random(%1,%2)")
492
            #else
NEW
493
                QString("random(%1,%2)")
×
494
            #endif
NEW
495
                .arg(minSB->value()).arg(maxSB->value()));
×
UNCOV
496
        m_editor->moveCursor(QTextCursor::EndOfLine);
×
497
    }
498
}
×
499

500
void ScriptEditor::slotAddFilePath()
×
501
{
502
    QString fn = getFilePath();
×
503
    if (fn.isEmpty())
×
504
        return;
×
505

506
    QFileInfo fInfo(fn);
×
507
    m_lastUsedPath = fInfo.absolutePath();
×
508

509
    //m_editor->textCursor().insertText(QUrl::toPercentEncoding(fn));
510
    m_editor->textCursor().insertText(fn);
×
511
}
512

513
void ScriptEditor::slotCheckSyntax()
×
514
{
515
    QString errResult;
×
516
    QString scriptText = m_document->toPlainText();
×
517
    m_script->setData(scriptText);
×
518

519
#ifdef QT_QML_LIB
520
    QStringList errLines = m_script->syntaxErrorsLines();
521
#else
522
    QList<int> errLines = m_script->syntaxErrorsLines();
×
523
#endif
524

UNCOV
525
    if (errLines.isEmpty())
×
526
    {
527
        errResult.append(tr("No syntax errors found in the script"));
×
528
    }
529
    else
530
    {
531
    #ifdef QT_QML_LIB
532
        errResult.append(errLines.join("\n"));
533
    #else
534
        QStringList lines = scriptText.split(QRegularExpression("(\\r\\n|\\n\\r|\\r|\\n)"));
×
535
        foreach (int line, errLines)
×
536
        {
537
            errResult.append(tr("Syntax error at line %1:\n%2\n\n").arg(line).arg(lines.at(line - 1)));
×
538
        }
539
    #endif
540
    }
541
    QMessageBox::information(this, tr("Script check results"), errResult);
×
542
}
×
543

544
void ScriptEditor::slotTestRun()
×
545
{
546
    if (m_testPlayButton->isChecked() == true)
×
547
        m_script->start(m_doc->masterTimer(), functionParent());
×
548
    else
549
        m_script->stopAndWait();
×
550
}
×
551

552
FunctionParent ScriptEditor::functionParent() const
×
553
{
554
    return FunctionParent::master();
×
555
}
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