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

rokucommunity / brighterscript / #15874

07 May 2026 05:57PM UTC coverage: 89.052% (+0.05%) from 89.006%
#15874

push

web-flow
Add diagnosticReporter config option (#1701)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

8779 of 10374 branches covered (84.63%)

Branch coverage included in aggregate %.

98 of 99 new or added lines in 2 files covered. (98.99%)

11043 of 11885 relevant lines covered (92.92%)

2051.08 hits per line

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

73.26
/src/ProgramBuilder.ts
1
import * as debounce from 'debounce-promise';
1✔
2
import * as path from 'path';
1✔
3
import { rokuDeploy } from 'roku-deploy';
1✔
4
import type { LogLevel as RokuDeployLogLevel } from 'roku-deploy/dist/Logger';
5
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
6
import type { BscFile, BsDiagnostic, FileObj, FileResolver } from './interfaces';
7
import { Program } from './Program';
1✔
8
import { standardizePath as s, util } from './util';
1✔
9
import { Watcher } from './Watcher';
1✔
10
import { DiagnosticSeverity } from 'vscode-languageserver';
1✔
11
import type { Logger } from './logging';
12
import { LogLevel, createLogger } from './logging';
1✔
13
import PluginInterface from './PluginInterface';
1✔
14
import * as diagnosticUtils from './diagnosticUtils';
1✔
15
import * as fsExtra from 'fs-extra';
1✔
16
import * as requireRelative from 'require-relative';
1✔
17
import { Throttler } from './Throttler';
1✔
18
import { URI } from 'vscode-uri';
1✔
19

20
/**
21
 * A runner class that handles
22
 */
23
export class ProgramBuilder {
1✔
24

25
    public constructor(
26
        options?: {
27
            logger?: Logger;
28
        }
29
    ) {
30
        this.logger = options?.logger ?? createLogger();
168✔
31
        this.plugins = new PluginInterface([], { logger: this.logger });
168✔
32

33
        //add the default file resolver (used to load source file contents).
34
        this.addFileResolver((filePath) => {
168✔
35
            return fsExtra.readFile(filePath).then((value) => {
93✔
36
                return value.toString();
93✔
37
            });
38
        });
39
    }
40
    /**
41
     * Determines whether the console should be cleared after a run (true for cli, false for languageserver)
42
     */
43
    public allowConsoleClearing = true;
168✔
44

45
    public options: FinalizedBsConfig = util.normalizeConfig({});
168✔
46
    private isRunning = false;
168✔
47
    private watcher: Watcher | undefined;
48
    public program: Program | undefined;
49
    public logger: Logger;
50
    public plugins: PluginInterface;
51
    private fileResolvers = [] as FileResolver[];
168✔
52

53
    /**
54
     * Add file resolvers that will be able to provide file contents before loading from the file system
55
     * @param fileResolvers a list of file resolvers
56
     */
57
    public addFileResolver(...fileResolvers: FileResolver[]) {
58
        this.fileResolvers.push(...fileResolvers);
168✔
59
    }
60

61
    /**
62
     * Get the contents of the specified file as a string.
63
     * This walks backwards through the file resolvers until we get a value.
64
     * This allow the language server to provide file contents directly from memory.
65
     */
66
    public async getFileContents(srcPath: string) {
67
        srcPath = s`${srcPath}`;
93✔
68
        let reversedResolvers = [...this.fileResolvers].reverse();
93✔
69
        for (let fileResolver of reversedResolvers) {
93✔
70
            let result = await fileResolver(srcPath);
98✔
71
            if (typeof result === 'string') {
98✔
72
                return result;
93✔
73
            }
74
        }
75
        throw new Error(`Could not load file "${srcPath}"`);
×
76
    }
77

78
    /**
79
     * A list of diagnostics that are always added to the `getDiagnostics()` call.
80
     */
81
    private staticDiagnostics = [] as BsDiagnostic[];
168✔
82

83
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
84
        if (!this.program) {
1!
85
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
86
        }
87
        let file: BscFile | undefined = this.program.getFile(srcPath);
1✔
88
        if (!file) {
1!
89
            file = {
1✔
90
                pkgPath: path.basename(srcPath),
91
                pathAbsolute: srcPath, //keep this for backwards-compatibility. TODO remove in v1
92
                srcPath: srcPath,
93
                getDiagnostics: () => {
94
                    return [<any>diagnostic];
×
95
                }
96
            } as BscFile;
97
        }
98
        diagnostic.file = file;
1✔
99
        this.staticDiagnostics.push(<any>diagnostic);
1✔
100
    }
101

102
    public getDiagnostics() {
103
        return [
315✔
104
            ...this.staticDiagnostics,
105
            ...(this.program?.getDiagnostics() ?? [])
1,890!
106
        ];
107
    }
108

109
    public async run(options: BsConfig) {
110
        if (options?.logLevel) {
151✔
111
            this.logger.logLevel = options.logLevel;
2✔
112
        }
113

114
        if (this.isRunning) {
151!
115
            throw new Error('Server is already running');
×
116
        }
117
        this.isRunning = true;
151✔
118
        try {
151✔
119
            this.options = util.normalizeAndResolveConfig(options);
151✔
120
            if (this.options?.logLevel !== undefined) {
149!
121
                this.logger.logLevel = this.options?.logLevel;
149!
122
            }
123

124
            if (this.options.noProject) {
149!
125
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
126
            } else if (this.options.project) {
149✔
127
                this.logger.log(`Using config file: "${this.options.project}"`);
59✔
128
            } else {
129
                this.logger.log(`No bsconfig.json file found, using default options`);
90✔
130
            }
131
            this.loadRequires();
149✔
132
            this.loadPlugins();
149✔
133
        } catch (e: any) {
134
            //For now, just use a default options object so we have a functioning program
135
            this.options = util.normalizeConfig({
2✔
136
                showDiagnosticsInConsole: options?.showDiagnosticsInConsole
6!
137
            });
138

139
            if (e?.file && e.message && e.code) {
2!
140
                let err = e as BsDiagnostic;
2✔
141
                this.staticDiagnostics.push(err);
2✔
142
            } else {
143
                //if this is not a diagnostic, something else is wrong...
144
                throw e;
×
145
            }
146

147
            this.printDiagnostics();
2✔
148
        }
149
        this.logger.logLevel = this.options.logLevel;
151✔
150

151
        this.createProgram();
151✔
152

153
        //parse every file in the entire project
154
        await this.loadAllFilesAST();
151✔
155

156
        if (this.options.watch) {
151!
157
            this.logger.log('Starting compilation in watch mode...');
×
158
            await this.runOnce({
×
159
                validate: options?.validate
×
160
            });
161
            this.enableWatchMode();
×
162
        } else {
163
            await this.runOnce({
151✔
164
                validate: options?.validate
453✔
165
            });
166
        }
167
    }
168

169
    protected createProgram() {
170
        this.program = new Program(this.options, this.logger, this.plugins);
152✔
171

172
        this.plugins.emit('afterProgramCreate', this.program);
152✔
173

174
        return this.program;
152✔
175
    }
176

177
    protected loadPlugins() {
178
        const cwd = this.options.cwd ?? process.cwd();
149!
179
        const plugins = util.loadPlugins(
149✔
180
            cwd,
181
            this.options.plugins ?? [],
447!
182
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
183
        );
184
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`, this.options.plugins);
149!
185
        for (let plugin of plugins) {
149✔
186
            this.plugins.add(plugin);
1✔
187
        }
188

189
        this.plugins.emit('beforeProgramCreate', this);
149✔
190
    }
191

192
    /**
193
     * `require()` every options.require path
194
     */
195
    protected loadRequires() {
196
        for (const dep of this.options.require ?? []) {
149✔
197
            requireRelative(dep, this.options.cwd);
2✔
198
        }
199
    }
200

201
    private clearConsole() {
202
        if (this.allowConsoleClearing) {
148!
203
            util.clearConsole();
148✔
204
        }
205
    }
206

207
    /**
208
     * A handle for the watch mode interval that keeps the process alive.
209
     * We need this so we can clear it if the builder is disposed
210
     */
211
    private watchInterval: NodeJS.Timer | undefined;
212

213
    public enableWatchMode() {
214
        this.watcher = new Watcher(this.options);
×
215
        if (this.watchInterval) {
×
216
            clearInterval(this.watchInterval);
×
217
        }
218
        //keep the process alive indefinitely by setting an interval that runs once every 12 days
219
        this.watchInterval = setInterval(() => { }, 1073741824);
×
220

221
        //clear the console
222
        this.clearConsole();
×
223

224
        let fileObjects = rokuDeploy.normalizeFilesArray(this.options.files ? this.options.files : []);
×
225

226
        //add each set of files to the file watcher
227
        for (let fileObject of fileObjects) {
×
228
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
229
            this.watcher.watch(src);
×
230
        }
231

232
        this.logger.log('Watching for file changes...');
×
233

234
        let debouncedRunOnce = debounce(async () => {
×
235
            this.logger.log('File change detected. Starting incremental compilation...');
×
236
            await this.runOnce();
×
237
            this.logger.log(`Watching for file changes.`);
×
238
        }, 50);
239

240
        //on any file watcher event
241
        this.watcher.on('all', async (event: string, thePath: string) => { //eslint-disable-line @typescript-eslint/no-misused-promises
×
242
            if (!this.program) {
×
243
                throw new Error('Internal invariant exception: somehow file watcher ran before `ProgramBuilder.run()`');
×
244
            }
245
            thePath = s`${path.resolve(this.rootDir, thePath)}`;
×
246
            if (event === 'add' || event === 'change') {
×
247
                const fileObj = {
×
248
                    src: thePath,
249
                    dest: rokuDeploy.getDestPath(
250
                        thePath,
251
                        this.program.options.files,
252
                        //some shells will toTowerCase the drive letter, so do it to rootDir for consistency
253
                        util.driveLetterToLower(this.rootDir)
254
                    )
255
                };
256
                this.program.setFile(
×
257
                    fileObj,
258
                    await this.getFileContents(fileObj.src)
259
                );
260
            } else if (event === 'unlink') {
×
261
                this.program.removeFile(thePath);
×
262
            }
263
            //wait for change events to settle, and then execute `run`
264
            await debouncedRunOnce();
×
265
        });
266
    }
267

268
    /**
269
     * The rootDir for this program.
270
     */
271
    public get rootDir() {
272
        if (!this.program) {
×
273
            throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`');
×
274
        }
275
        return this.program.options.rootDir;
×
276
    }
277

278
    /**
279
     * A method that is used to cancel a previous run task.
280
     * Does nothing if previous run has completed or was already canceled
281
     */
282
    private cancelLastRun = () => {
168✔
283
        return Promise.resolve();
148✔
284
    };
285

286
    /**
287
     * Run the entire process exactly one time.
288
     */
289
    private runOnce(options?: { validate?: boolean }) {
290
        //clear the console
291
        this.clearConsole();
148✔
292
        let cancellationToken = { isCanceled: false };
148✔
293
        //wait for the previous run to complete
294
        let runPromise = this.cancelLastRun().then(() => {
148✔
295
            //start the new run
296
            return this._runOnce({
148✔
297
                cancellationToken: cancellationToken,
298
                validate: options?.validate
444!
299
            });
300
        }) as any;
301

302
        //a function used to cancel this run
303
        this.cancelLastRun = () => {
148✔
304
            cancellationToken.isCanceled = true;
×
305
            return runPromise;
×
306
        };
307
        return runPromise;
148✔
308
    }
309

310
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
311
        if (this.options?.showDiagnosticsInConsole === false) {
158!
312
            return;
140✔
313
        }
314
        if (!diagnostics) {
18✔
315
            diagnostics = this.getDiagnostics();
8✔
316
        }
317

318
        //group the diagnostics by file
319
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
18✔
320
        for (let diagnostic of diagnostics) {
18✔
321
            if (!diagnosticsByFile[diagnostic.file.srcPath]) {
9✔
322
                diagnosticsByFile[diagnostic.file.srcPath] = [];
8✔
323
            }
324
            diagnosticsByFile[diagnostic.file.srcPath].push(diagnostic);
9✔
325
        }
326

327
        //get printing options
328
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
18✔
329
        const { cwd, emitFullPaths } = options;
18✔
330
        //custom-template reporters are pre-resolved once so we don't recompile them per diagnostic;
331
        //the resolved function is stashed on the entry as `run` so we don't have to keep a parallel array.
332
        //invalid entries are warned about and skipped (we never want to abort a build over a config typo).
333
        const reporters = diagnosticUtils.normalizeDiagnosticReporters(
18✔
334
            this.options?.diagnosticReporters,
54!
335
            this.logger
336
        )
337
            .map(reporter => (reporter.type === 'custom'
22✔
338
                ? { ...reporter, run: diagnosticUtils.createCustomDiagnosticReporter(reporter.format) }
22✔
339
                : reporter));
340
        if (reporters.length === 0) {
18!
NEW
341
            return;
×
342
        }
343

344
        let srcPaths = Object.keys(diagnosticsByFile).sort();
18✔
345
        for (let srcPath of srcPaths) {
18✔
346
            let diagnosticsForFile = diagnosticsByFile[srcPath];
8✔
347
            //sort the diagnostics in line and column order
348
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
8✔
349
                return (
1✔
350
                    (a.range?.start.line ?? -1) - (b.range?.start.line ?? -1) ||
14!
351
                    (a.range?.start.character ?? -1) - (b.range?.start.character ?? -1)
12!
352
                );
353
            });
354

355
            let filePath = srcPath;
8✔
356
            if (!emitFullPaths) {
8!
357
                filePath = path.relative(cwd, filePath);
8✔
358
            }
359
            //load the file text
360
            const file = this.program?.getFile(srcPath);
8!
361
            //get the file's in-memory contents if available
362
            const lines = file?.fileContents?.split(/\r?\n/g) ?? [];
8✔
363

364
            for (let diagnostic of sortedDiagnostics) {
8✔
365
                //default the severity to error if undefined
366
                let severity = typeof diagnostic.severity === 'number' ? diagnostic.severity : DiagnosticSeverity.Error;
9✔
367
                let relatedInformation = (diagnostic.relatedInformation ?? []).map(x => {
9!
368
                    let relatedInfoFilePath = URI.parse(x.location.uri).fsPath;
×
369
                    if (!emitFullPaths) {
×
370
                        relatedInfoFilePath = path.relative(cwd, relatedInfoFilePath);
×
371
                    }
372
                    return {
×
373
                        filePath: relatedInfoFilePath,
374
                        range: x.location.range,
375
                        message: x.message
376
                    };
377
                });
378
                //format output once per configured reporter
379
                for (const reporter of reporters) {
9✔
380
                    if (reporter.type === 'github-actions') {
13✔
381
                        diagnosticUtils.printDiagnosticGithubActions({ options: options, severity: severity, filePath: filePath, diagnostic: diagnostic });
2✔
382
                    } else if (reporter.type === 'custom') {
11✔
383
                        reporter.run({ options: options, severity: severity, filePath: filePath, diagnostic: diagnostic });
2✔
384
                    } else {
385
                        diagnosticUtils.printDiagnostic(options, severity, filePath, lines, diagnostic, relatedInformation);
9✔
386
                    }
387
                }
388
            }
389
        }
390
    }
391

392
    /**
393
     * Run the process once, allowing it to be cancelled.
394
     * NOTE: This should only be called by `runOnce`.
395
     */
396
    private async _runOnce(options: { cancellationToken: { isCanceled: any }; validate?: boolean }) {
397
        let wereDiagnosticsPrinted = false;
148✔
398
        try {
148✔
399
            //maybe cancel?
400
            if (options?.cancellationToken?.isCanceled === true) {
148!
401
                return -1;
×
402
            }
403
            //the prop-drilled validate value takes precedence over this.options.validate.
404
            //false means no, everything else (including missing) means true
405
            if ((options?.validate ?? this.options.validate) !== false) {
148!
406
                this.validateProject();
7✔
407
            }
408

409
            //maybe cancel?
410
            if (options?.cancellationToken?.isCanceled === true) {
148!
411
                return -1;
×
412
            }
413

414
            const diagnostics = this.getDiagnostics();
148✔
415
            this.printDiagnostics(diagnostics);
148✔
416
            wereDiagnosticsPrinted = true;
148✔
417
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
148✔
418

419
            if (errorCount > 0) {
148✔
420
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
2!
421
                return errorCount;
2✔
422
            }
423

424
            //create the deployment package (and transpile as well)
425
            await this.createPackageIfEnabled();
146✔
426

427
            //maybe cancel?
428
            if (options?.cancellationToken?.isCanceled === true) {
146!
429
                return -1;
×
430
            }
431

432
            //deploy the package
433
            await this.deployPackageIfEnabled();
146✔
434

435
            return 0;
146✔
436
        } catch (e) {
437
            if (wereDiagnosticsPrinted === false) {
×
438
                this.printDiagnostics();
×
439
            }
440
            throw e;
×
441
        }
442
    }
443

444
    private async createPackageIfEnabled() {
445
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
146✔
446

447
            //transpile the project
448
            await this.transpile();
4✔
449

450
            //create the zip file if configured to do so
451
            if (this.options.createPackage !== false || this.options.deploy) {
4✔
452
                await this.logger.time(LogLevel.log, [`Creating package at ${this.options.outFile}`], async () => {
2✔
453
                    await rokuDeploy.zipPackage({
2✔
454
                        ...this.options,
455
                        logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
456
                        outDir: util.getOutDir(this.options),
457
                        outFile: path.basename(this.options.outFile)
458
                    });
459
                });
460
            }
461
        }
462
    }
463

464
    private transpileThrottler = new Throttler(0);
168✔
465
    /**
466
     * Transpiles the entire program into the staging folder
467
     */
468
    public async transpile() {
469
        await this.transpileThrottler.run(async () => {
4✔
470
            let options = util.cwdWork(this.options.cwd, () => {
4✔
471
                return rokuDeploy.getOptions({
4✔
472
                    ...this.options,
473
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
474
                    outDir: util.getOutDir(this.options),
475
                    outFile: path.basename(this.options.outFile)
476

477
                    //rokuDeploy's return type says all its fields can be nullable, but it sets values for all of them.
478
                }) as any as Required<ReturnType<typeof rokuDeploy.getOptions>>;
479
            });
480

481
            //get every file referenced by the files array
482
            let fileMap = await rokuDeploy.getFilePaths(options.files, options.rootDir);
4✔
483

484
            //remove files currently loaded in the program, we will transpile those instead (even if just for source maps)
485
            let filteredFileMap = [] as FileObj[];
4✔
486

487
            for (let fileEntry of fileMap) {
4✔
488
                if (this.program!.hasFile(fileEntry.src) === false) {
4✔
489
                    filteredFileMap.push(fileEntry);
2✔
490
                }
491
            }
492

493
            this.plugins.emit('beforePrepublish', this, filteredFileMap);
4✔
494

495
            await this.logger.time(LogLevel.log, ['Copying to staging directory'], async () => {
4✔
496
                //prepublish all non-program-loaded files to staging
497
                await rokuDeploy.prepublishToStaging({
4✔
498
                    ...options,
499
                    files: filteredFileMap
500
                });
501
            });
502

503
            this.plugins.emit('afterPrepublish', this, filteredFileMap);
4✔
504
            this.plugins.emit('beforePublish', this, fileMap);
4✔
505

506
            await this.logger.time(LogLevel.log, ['Transpiling'], async () => {
4✔
507
                //transpile any brighterscript files
508
                await this.program!.transpile(fileMap, options.stagingDir);
4✔
509
            });
510

511
            this.plugins.emit('afterPublish', this, fileMap);
4✔
512
        });
513
    }
514

515
    private async deployPackageIfEnabled() {
516
        //deploy the project if configured to do so
517
        if (this.options.deploy) {
146!
518
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
519
                await rokuDeploy.publish({
×
520
                    ...this.options,
521
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
522
                    outDir: util.getOutDir(this.options),
523
                    outFile: path.basename(this.options.outFile)
524
                });
525
            });
526
        }
527
    }
528

529
    /**
530
     * Parse and load the AST for every file in the project
531
     */
532
    private async loadAllFilesAST() {
533
        await this.logger.time(LogLevel.log, ['Parsing files'], async () => {
153✔
534
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
153✔
535
                return util.getFilePaths(this.options);
153✔
536
            });
537
            this.logger.trace('ProgramBuilder.loadAllFilesAST() files:', files);
153✔
538

539
            const typedefFiles = [] as FileObj[];
153✔
540
            const sourceFiles = [] as FileObj[];
153✔
541
            let manifestFile: FileObj | null = null;
153✔
542

543
            for (const file of files) {
153✔
544
                // source files (.brs, .bs, .xml)
545
                if (/(?<!\.d)\.(bs|brs|xml)$/i.test(file.dest)) {
116✔
546
                    sourceFiles.push(file);
97✔
547

548
                    // typedef files (.d.bs)
549
                } else if (/\.d\.bs$/i.test(file.dest)) {
19✔
550
                    typedefFiles.push(file);
2✔
551

552
                    // manifest file
553
                } else if (/^manifest$/i.test(file.dest)) {
17✔
554
                    manifestFile = file;
16✔
555
                }
556
            }
557

558
            if (manifestFile) {
153✔
559
                this.program!.loadManifest(manifestFile, false);
16✔
560
            }
561

562
            const loadFile = async (fileObj) => {
153✔
563
                try {
99✔
564
                    this.program!.setFile(fileObj, await this.getFileContents(fileObj.src));
99✔
565
                } catch (e) {
566
                    this.logger.log(e); // log the error, but don't fail this process because the file might be fixable later
×
567
                }
568
            };
569
            await Promise.all(typedefFiles.map(loadFile)); // preload every type definition file, which eliminates duplicate file loading
153✔
570
            await Promise.all(sourceFiles.map(loadFile)); // parse source files
153✔
571
        });
572
    }
573

574
    /**
575
     * Remove all files from the program that are in the specified folder path
576
     * @param srcPath the path to the folder to remove
577
     */
578
    public removeFilesInFolder(srcPath: string): boolean {
579
        let removedSomeFiles = false;
×
580
        for (let filePath in this.program.files) {
×
581
            //if the file path starts with the parent path and the file path does not exactly match the folder path
582
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
583
                this.program.removeFile(filePath);
×
584
                removedSomeFiles = true;
×
585
            }
586
        }
587
        return removedSomeFiles;
×
588
    }
589

590
    /**
591
     * Scan every file and resolve all variable references.
592
     * If no errors were encountered, return true. Otherwise return false.
593
     */
594
    private validateProject() {
595
        this.program.validate();
6✔
596
    }
597

598
    public dispose() {
599
        if (this.watcher) {
162!
600
            this.watcher.dispose();
×
601
        }
602
        if (this.program) {
162!
603
            this.program.dispose?.();
162!
604
        }
605
        if (this.watchInterval) {
162!
606
            clearInterval(this.watchInterval);
×
607
        }
608
    }
609
}
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