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

rokucommunity / vscode-brightscript-language / 30096694707

24 Jul 2026 01:23PM UTC coverage: 59.949% (+0.04%) from 59.908%
30096694707

Pull #802

github

web-flow
Merge 333fa7808 into e465804e3
Pull Request #802: Route extension logs through the BrightScript Extension output channel

2592 of 4745 branches covered (54.63%)

Branch coverage included in aggregate %.

19 of 23 new or added lines in 3 files covered. (82.61%)

732 existing lines in 18 files now uncovered.

4036 of 6311 relevant lines covered (63.95%)

43.79 hits per line

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

69.36
/src/LogOutputManager.ts
1
import * as vscode from 'vscode';
1✔
2
import { isChanperfEvent, isLaunchStartEvent, isLogOutputEvent, isRendezvousEvent } from 'roku-debug';
1✔
3
import type { DeclarationProvider } from './DeclarationProvider';
4
import type { LogDocumentLinkProvider } from './LogDocumentLinkProvider';
5
import { CustomDocumentLink } from './LogDocumentLinkProvider';
1✔
6
import { util } from './util';
1✔
7
import * as fsExtra from 'fs-extra';
1✔
8
import type { BrightScriptLaunchConfiguration } from './DebugConfigurationProvider';
9
import stripAnsi from 'strip-ansi';
1✔
10

11
export class LogLine {
1✔
12
    constructor(
13
        public text: string,
65✔
14
        public isMustInclude: boolean
65✔
15
    ) {
16
    }
17
}
18

19
export class LogOutputManager {
1✔
20

21
    constructor(
22
        outputChannel,
23
        context,
24
        docLinkProvider,
25
        private declarationProvider: DeclarationProvider
45✔
26
    ) {
27
        this.outputChannel = outputChannel;
45✔
28
        this.docLinkProvider = docLinkProvider;
45✔
29

30
        this.loadConfigSettings();
45✔
31
        vscode.workspace.onDidChangeConfiguration((e) => {
45✔
UNCOV
32
            this.loadConfigSettings();
×
33
        });
34

35
        let subscriptions = context.subscriptions;
45✔
36
        this.includeRegex = null;
45✔
37
        this.logLevelRegex = null;
45✔
38
        this.excludeRegex = null;
45✔
39
        /**
40
         * we want to catch a few different link formats here:
41
         *  - pkg:/path/file.brs(LINE:COL)
42
         *  - file://path/file.bs:LINE
43
         *  - at line LINE of file pkg:/path/file.brs - this case can arise when the device reports various scenegraph errors such as fields not present, or texture size issues, etc
44
         */
45
        this.pkgRegex = /(?:\s*at line (\d*) of file )*(?:(pkg:\/|file:\/\/)(.*\.(bs|brs|xml)))[ \t]*(?:(?:(?:\()(\d+)(?:\:(\d+))?\)?)|(?:\:(\d+)?))*/;
45✔
46
        this.debugStartRegex = /BrightScript Micro Debugger\./ig;
45✔
47
        this.debugEndRegex = /Brightscript Debugger>/ig;
45✔
48

49
        subscriptions.push(vscode.commands.registerCommand('extension.brightscript.markLogOutput', () => {
45✔
UNCOV
50
            this.markOutput();
×
51
        }));
52
        subscriptions.push(vscode.commands.registerCommand('extension.brightscript.clearLogOutput', () => {
45✔
53
            this.clearOutput();
×
54
        }));
55

56
        subscriptions.push(vscode.commands.registerCommand('extension.brightscript.setOutputIncludeFilter', async () => {
45✔
UNCOV
57
            let entryText: string = await vscode.window.showInputBox({
×
58
                placeHolder: 'Enter log include regex',
59
                value: this.includeRegex ? this.includeRegex.source : ''
×
60
            });
UNCOV
61
            if (entryText || entryText === '') {
×
UNCOV
62
                this.setIncludeFilter(entryText);
×
63
            }
64
        }));
65

66
        subscriptions.push(vscode.commands.registerCommand('extension.brightscript.setOutputLogLevelFilter', async () => {
45✔
UNCOV
67
            let entryText: string = await vscode.window.showInputBox({
×
68
                placeHolder: 'Enter log level regex',
69
                value: this.logLevelRegex ? this.logLevelRegex.source : ''
×
70
            });
UNCOV
71
            if (entryText || entryText === '') {
×
UNCOV
72
                this.setLevelFilter(entryText);
×
73
            }
74
        }));
75

76
        subscriptions.push(vscode.commands.registerCommand('extension.brightscript.setOutputExcludeFilter', async () => {
45✔
UNCOV
77
            let entryText: string = await vscode.window.showInputBox({
×
78
                placeHolder: 'Enter log exclude regex',
79
                value: this.excludeRegex ? this.excludeRegex.source : ''
×
80
            });
UNCOV
81
            if (entryText || entryText === '') {
×
UNCOV
82
                this.setExcludeFilter(entryText);
×
83
            }
84
        }));
85
        this.clearOutput();
45✔
86

87
    }
88
    private displayedLogLines: LogLine[];
89
    private allLogLines: LogLine[];
90
    private markCount: number;
91
    private includeRegex?: RegExp;
92
    private logLevelRegex?: RegExp;
93
    private excludeRegex?: RegExp;
94
    private pkgRegex: RegExp;
95
    private isNextBreakpointSkipped = false;
45✔
96
    private includeStackTraces: boolean;
97
    private isInMicroDebugger: boolean;
98
    public launchConfig: BrightScriptLaunchConfiguration;
99
    public isFocusingOutputOnLaunch: boolean;
100
    public isClearingOutputOnLaunch: boolean;
101
    public isClearingConsoleOnChannelStart: boolean;
102
    public hyperlinkFormat: string;
103
    private outputChannel: vscode.OutputChannel;
104
    private docLinkProvider: LogDocumentLinkProvider;
105
    private debugStartRegex: RegExp;
106
    private debugEndRegex: RegExp;
107

108
    public onDidStartDebugSession() {
109
        this.isInMicroDebugger = false;
2✔
110
        this.isNextBreakpointSkipped = false;
2✔
111
        if (this.isClearingConsoleOnChannelStart) {
2✔
112
            this.clearOutput();
1✔
113
        }
114
    }
115

116
    private loadConfigSettings() {
117
        let config: any = util.getConfiguration('brightscript') || {};
45!
118
        this.includeStackTraces = config.output?.includeStackTraces;
45!
119
        this.isFocusingOutputOnLaunch = config?.output?.focusOnLaunch === false ? false : true;
45!
120
        this.isClearingOutputOnLaunch = config?.output?.clearOnLaunch === false ? false : true;
45!
121
        this.isClearingConsoleOnChannelStart = config?.output?.clearConsoleOnChannelStart === false ? false : true;
45!
122
        this.hyperlinkFormat = config.output?.hyperlinkFormat;
45!
123
    }
124

125
    public setLaunchConfig(launchConfig: BrightScriptLaunchConfiguration) {
UNCOV
126
        this.launchConfig = launchConfig;
×
127
    }
128

129
    public async onDidReceiveDebugSessionCustomEvent(e: { event: string; body?: any }) {
130
        if (isRendezvousEvent(e) || isChanperfEvent(e)) {
5!
131
            // No need to handle rendezvous type events
UNCOV
132
            return;
×
133
        }
134

135
        if (isLogOutputEvent(e)) {
5✔
136

137
            const errorCodes = new Map<string, string>([
1✔
138
                ['&h00', '(ERR_NF: next without for)'],
139
                ['&h02', '(ERR_SYNTAX): syntax error'],
140
                ['&h04', '(ERR_RG: return without gosub)'],
141
                ['&h06', '(ERR_OD: out of data (READ))'],
142
                ['&h08', '(ERR_FC: invalid parameter passed to function/array (e.g neg matrix dim or square root))'],
143
                ['&h0C', '(ERR_OUTOFMEM: out of memory)'],
144
                ['&h0E', '(ERR_MISSING_LN: missing line)'],
145
                ['&h10', '(ERR_BS: array subscript out of bounds)'],
146
                ['&h12', '(ERR_DD: attempted to redimension an array)'],
147
                ['&h14', '(ERR_DIV_ZERO: divide by zero error)'],
148
                ['&h18', '(ERR_TM: type mismatch (string / numeric operation mismatch))'],
149
                ['&h1A', '(ERR_OS: out of string space)'],
150
                ['&h1C', '(ERR_STRINGTOLONG: error parsing string to long)'],
151
                ['&h20', '(ERR_CN: continue not allowed)'],
152
                ['&h9F', '(ERR_INVALID_CONST_NAME: invalid constant name)'],
153
                ['&hA0', '(ERR_VAR_CANNOT_BE_SUBNAME: invalid subroutine name)'],
154
                ['&hA1', '(ERR_TOO_MANY_LABELS)'],
155
                ['&hA2', '(ERR_RO_NOT_FOUND:)'],
156
                ['&hA3', '(ERR_IF_TOO_LARGE:)'],
157
                ['&hA4', '(ERR_MISSING_INITILIZER:)'],
158
                ['&hA5', '(ERR_EXIT_FOR_NOT_IN_FOR: exit for statement found when not in for loop)'],
159
                ['&hA6', '(ERR_NOLONGER: no longer supported)'],
160
                ['&hA7', '(ERR_INVALID_TYPE: invalid typhe)'],
161
                ['&hA8', '(ERR_FUN_MUST_HAVE_RET_TYPE: function must have a return type)'],
162
                ['&hA9', '(ERR_RET_MUST_HAVE_VALUE: return statement must have a value)'],
163
                ['&hAA', '(ERR_RET_CANNOT_HAVE_VALUE: return statement cannot have a value)'],
164
                ['&hAB', '(ERR_FOREACH_INDEX_TM)'],
165
                ['&hAC', '(ERR_NOMAIN: no main function present)'],
166
                ['&hAD', '(ERR_SUB_DEFINED_TWICE: sub defined twice)'],
167
                ['&hAE', '(ERR_INTERNAL_LIMIT_EXCEDED: internal limit exceeded)'],
168
                ['&hAF', '(ERR_EXIT_WHILE_NOT_IN_WHILE: exit while statement found when not in while loop)'],
169
                ['&hB0', '(ERR_TOO_MANY_VAR: too many variables)'],
170
                ['&hB1', '(ERR_TOO_MANY_CONST: too many constants)'],
171
                ['&hB2', '(ERR_FUN_NOT_EXPECTED: function not expected)'],
172
                ['&hB3', '(ERR_UNTERMED_STRING: literal string does not have ending quote)'],
173
                ['&hB4', '(ERR_LABELTWICE: label defined more than once)'],
174
                ['&hB5', '(ERR_NO_BLOCK_END: no end to block)'],
175
                ['&hB6', '(ERR_FOR_NEXT_MISMATCH: variable on a NEXT does not match that for the FOR)'],
176
                ['&hB7', '(ERR_UNEXPECTED_EOF: end of string being compiled encountered when not expected (missing end of block usually))'],
177
                ['&hB8', '(ERR_NOMATCH: "match" statement did not match)'],
178
                ['&hB9', '(ERR_LOADFILE: error loading a file)'],
179
                ['&hBA', '(ERR_LNSEQ: line number sequence error)'],
180
                ['&hBB', '(ERR_NOLN: no line number found)'],
181
                ['&hBC', '(ERR_MISSING_ENDIF: end of code reached without finding ENDIF)'],
182
                ['&hBE', '(ERR_MISSING_ENDWHILE: while statement is missing a matching endwhile)'],
183
                ['&hBF', '(ERR_NW: endwhile with no while)'],
184
                ['&hDF', '(ERR_STACK_OVERFLOW): stack overflow error'],
185
                ['&hE0', '(ERR_NOTFUNOPABLE)'],
186
                ['&hE1', '(ERR_UNICODE_NOT_SUPPORTED: unicode character not supported)'],
187
                ['&hE2', '(ERR_VALUE_RETURN: return executed, and a value returned on the stack)'],
188
                ['&hE3', '(ERR_INVALID_NUM_ARRAY_IDX: invalid number of array indexes)'],
189
                ['&hE4', '(ERR_INVALID_LVALUE: invalid left side of expression)'],
190
                ['&hE5', '(ERR_MUST_HAVE_RETURN: function must have return value)'],
191
                ['&hE6', '(ERR_USE_OF_UNINIT_BRSUBREF: used a reference to SUB that is not initialized)'],
192
                ['&hE7', '(ERR_ARRAYNOTDIMMED: array has not been dimensioned)'],
193
                ['&hE8', '(ERR_TM2: non-numeric index to array)'],
194
                ['&hE9', '(ERR_USE_OF_UNINIT_VAR: illegal use of uninitialized var)'],
195
                ['&hEB', '(ERR_NOTYPEOP: operation on two typeless operands attempted)'],
196
                ['&hEC', '(ERR_RO4: . (dot) operator used on a variable that does not contain a legal object or interface reference)'],
197
                ['&hED', '(ERR_MUST_BE_STATIC: interface calls from type rotINTERFACE must be static)'],
198
                ['&hEE', '(ERR_NOTWAITABLE: tried to wait on a function that does not have MessagePort interface)'],
199
                ['&hEF', '(ERR_NOTPRINTABLE: non-printable value)'],
200
                ['&hF0', '(ERR_RVIG: function returns a value, but is ignored)'],
201
                ['&hF1', '(ERR_WRONG_NUM_PARAM: incorect number of function parameters)'],
202
                ['&hF2', '(ERR_TOO_MANY_PARAM: too many function parameters to handle)'],
203
                ['&hF3', '(ERR_RO3: interface not a member of object)'],
204
                ['&hF4', '(ERR_RO2: member function not found in object or interface)'],
205
                ['&hF5', '(ERR_RO1: function call does not have the right number of parameters)'],
206
                ['&hF6', '(ERR_RO0: bscNewComponent failed because object class not found)'],
207
                ['&hF7', '(ERR_STOP: stop statement executed)'],
208
                ['&hF8', '(ERR_BREAK: scriptBreak() called)'],
209
                ['&hF9', '(ERR_STACK_UNDER: nothing on stack to pop)'],
210
                ['&hFA', '(ERR_MISSING_PARN)'],
211
                ['&hFB', '(ERR_UNDEFINED_OP: an expression operator that we do not handle)'],
212
                ['&hFC', '(ERR_NORMAL_END: normal, but terminate execution.  END, shell "exit", window closed, etc'],
213
                ['&hFD', '(ERR_UNDEFINED_OPCD: an opcode that we do not handle )'],
214
                ['&hFE', '(ERR_INTERNAL: a condition that should not occur did)'],
215
                ['&hFF', '(ERR_OKAY)']
216
            ]);
217

218
            // need to upgrade this includes statement to include a full hex regex
219
            if (e.body.line.includes('&h')) {
1!
UNCOV
220
                const regexGlobal = /&h[0-9A-F][0-9A-F]/g;
×
221
                let regexMatches: RegExpExecArray;
UNCOV
222
                while ((regexMatches = regexGlobal.exec(e.body.line)) !== null) {
×
223
                    const regexMatch: string = regexMatches[0];
×
UNCOV
224
                    if (errorCodes.has(regexMatch)) {
×
225
                        const errorDescription = errorCodes.get(regexMatch);
×
226
                        e.body.line = e.body.line.replace(regexMatch, regexMatch + ' ' + errorDescription + ' ');
×
227
                    }
228
                }
229
            }
230
            this.appendLine(e.body.line);
1✔
231

232
        } else if (isLaunchStartEvent(e)) {
4✔
233
            this.isInMicroDebugger = false;
2✔
234
            this.isNextBreakpointSkipped = false;
2✔
235
            if (this.isFocusingOutputOnLaunch) {
2!
236
                await vscode.commands.executeCommand('workbench.action.focusPanel');
2✔
237
                this.outputChannel.show();
2✔
238
            }
239
            if (this.isClearingOutputOnLaunch) {
2✔
240
                this.clearOutput();
1✔
241
            }
242

243
        }
244
    }
245

246
    /**
247
     * Log output methods
248
     */
249
    public appendLine(lineText: string, mustInclude = false): void {
1✔
250
        let lines = lineText.split(/\r?\n/g);
6✔
251

252
        // Remove the last line if it's empty as a result of a trailing newline
253
        if (lines[lines.length - 1] === '') {
6!
UNCOV
254
            lines.pop();
×
255
        }
256

257
        for (let line of lines) {
6✔
258
            if (line !== '') {
7!
259
                if (!this.includeStackTraces) {
7!
260
                    // filter out debugger noise
261
                    if (this.debugStartRegex.exec(line)) {
7!
UNCOV
262
                        console.log('start MicroDebugger block');
×
UNCOV
263
                        this.isInMicroDebugger = true;
×
UNCOV
264
                        this.isNextBreakpointSkipped = false;
×
265
                        line = 'Pausing for a breakpoint...';
×
266
                    } else if (this.isInMicroDebugger && (this.debugEndRegex.exec(line))) {
7!
267
                        console.log('ended MicroDebugger block');
×
268
                        this.isInMicroDebugger = false;
×
UNCOV
269
                        if (this.isNextBreakpointSkipped) {
×
270
                            line = '\n**Was a bogus breakpoint** Skipping!\n';
×
271
                        } else {
272
                            line = null;
×
273
                        }
274
                    } else if (this.isInMicroDebugger) {
7!
275
                        if (this.launchConfig.enableDebuggerAutoRecovery && line.startsWith('Break in ')) {
×
UNCOV
276
                            console.log('this block is a break: skipping it');
×
UNCOV
277
                            this.isNextBreakpointSkipped = true;
×
278
                        }
279
                        line = null;
×
280
                    }
281
                }
282
            }
283
            if (typeof line === 'string') {
7!
284
                // Strip ANSI color codes
285
                line = stripAnsi(line);
7✔
286
                const logLine = new LogLine(line, mustInclude);
7✔
287
                this.allLogLines.push(logLine);
7✔
288
                if (this.shouldLineBeShown(logLine)) {
7!
289
                    this.allLogLines.push(logLine);
7✔
290
                    this.addLogLineToOutput(logLine);
7✔
291
                    this.writeLogLineToLogfile(logLine.text);
7✔
292
                }
293
            }
294
        }
295
    }
296

297
    public writeLogLineToLogfile(text: string) {
298
        if (this.launchConfig?.logfilePath) {
7!
UNCOV
299
            fsExtra.appendFileSync(this.launchConfig.logfilePath, text + '\n');
×
300
        }
301
    }
302

303
    public addLogLineToOutput(logLine: LogLine) {
304
        const logLineNumber = this.displayedLogLines.length;
31✔
305
        if (this.shouldLineBeShown(logLine)) {
31!
306
            this.displayedLogLines.push(logLine);
31✔
307
            let match = this.pkgRegex.exec(logLine.text);
31✔
308
            if (match) {
31✔
309
                const isFilePath = match[2] === 'file://';
24✔
310
                const path = isFilePath ? match[3] : 'pkg:/' + match[3];
24✔
311
                let lineNumber = match[1] ? Number(match[1]) : undefined;
24!
312
                if (!lineNumber) {
24!
313
                    if (isFilePath) {
24✔
314
                        lineNumber = Number(match[7]);
3✔
315
                        if (isNaN(lineNumber)) {
3!
316
                            lineNumber = Number(match[5]);
3✔
317
                        }
318
                    } else {
319
                        lineNumber = Number(match[5]);
21✔
320
                    }
321
                }
322

323
                const filename = this.getFilename(path);
24✔
324
                const ext = `.${match[4]}`.toLowerCase();
24✔
325
                let customText = this.getCustomLogText(path, filename, ext, Number(lineNumber), logLineNumber, isFilePath);
24✔
326
                const customLink = new CustomDocumentLink(logLineNumber, match.index, customText.length, path, lineNumber, filename);
24✔
327
                if (isFilePath) {
24✔
328
                    this.docLinkProvider.addCustomFileLink(customLink);
3✔
329
                } else {
330
                    this.docLinkProvider.addCustomPkgLink(customLink);
21✔
331
                }
332
                let logText = logLine.text.substring(0, match.index) + customText + logLine.text.substring(match.index + match[0].length);
24✔
333
                this.outputChannel.appendLine(logText);
24✔
334
            } else {
335
                this.outputChannel.appendLine(logLine.text);
7✔
336
            }
337
        }
338
    }
339

340
    public getFilename(pkgPath: string): string {
341
        const parts = pkgPath.split('/');
34✔
342
        let name = parts.length > 0 ? parts[parts.length - 1] : pkgPath;
34!
343
        if (name.toLowerCase().endsWith('.xml') || name.toLowerCase().endsWith('.brs')) {
34✔
344
            name = name.substring(0, name.length - 4);
33✔
345
        } else if (name.toLowerCase().endsWith('.bs')) {
1!
UNCOV
346
            name = name.substring(0, name.length - 3);
×
347
        }
348
        return name;
34✔
349
    }
350

351
    public getCustomLogText(pkgPath: string, filename: string, extension: string, lineNumber: number, logLineNumber: number, isFilePath: boolean): string {
352
        switch (this.hyperlinkFormat) {
72✔
353
            case 'Full':
354
                return pkgPath + `(${lineNumber})`;
2✔
UNCOV
355
                break;
×
356
            case 'Short':
357
                return `#${logLineNumber}`;
2✔
358
                break;
×
359
            case 'Hidden':
360
                return ' ';
2✔
361
                break;
×
362
            case 'Filename':
363
                return `${filename}${extension}(${lineNumber})`;
12✔
364
                break;
×
365
            default:
366
                if (extension === '.brs' || extension === '.bs') {
54!
367
                    const methodName = this.getMethodName(pkgPath, lineNumber, isFilePath);
54✔
368
                    if (methodName) {
54!
369
                        return `${filename}.${methodName}(${lineNumber})`;
54✔
370
                    }
371
                }
UNCOV
372
                return pkgPath + `(${lineNumber})`;
×
UNCOV
373
                break;
×
374
        }
375
    }
376

377
    public getMethodName(path: string, lineNumber: number, isFilePath: boolean): string | null {
378
        let fsPath = isFilePath ? path : this.docLinkProvider.convertPkgPathToFsPath(path);
54✔
379
        const method = fsPath ? this.declarationProvider.getFunctionBeforeLine(fsPath, lineNumber) : null;
54!
380
        return method ? method.name : null;
54!
381
    }
382

383
    private shouldLineBeShown(logLine: LogLine): boolean {
384
        //filter excluded lines
385
        if (this.excludeRegex?.test(logLine.text)) {
72✔
386
            return false;
6✔
387
        }
388
        //once past the exclude filter, always keep "mustInclude" lines
389
        if (logLine.isMustInclude) {
66✔
390
            return true;
44✔
391
        }
392
        //throw out lines that don't match the logLevelRegex (if we have one)
393
        if (this.logLevelRegex && !this.logLevelRegex.test(logLine.text)) {
22✔
394
            return false;
6✔
395
        }
396
        //throw out lines that don't match the includeRegex (if we have one)
397
        if (this.includeRegex && !this.includeRegex.test(logLine.text)) {
16✔
398
            return false;
6✔
399
        }
400
        //all other log entries should be kept
401
        return true;
10✔
402
    }
403

404
    public clearOutput(): any {
405
        this.markCount = 0;
49✔
406
        this.allLogLines = [];
49✔
407
        this.displayedLogLines = [];
49✔
408
        this.outputChannel.clear();
49✔
409
        this.docLinkProvider.resetCustomLinks();
49✔
410
    }
411

412
    public setIncludeFilter(text: string): void {
413
        this.includeRegex = text && text.trim() !== '' ? new RegExp(text, 'i') : null;
35✔
414
        this.reFilterOutput();
35✔
415
    }
416

417
    public setExcludeFilter(text: string): void {
418
        this.excludeRegex = text && text.trim() !== '' ? new RegExp(text, 'i') : null;
35✔
419
        this.reFilterOutput();
35✔
420
    }
421

422
    public setLevelFilter(text: string): void {
423
        this.logLevelRegex = text && text.trim() !== '' ? new RegExp(text, 'i') : null;
35✔
424
        this.reFilterOutput();
35✔
425
    }
426

427
    public reFilterOutput(): void {
428
        this.outputChannel.clear();
102✔
429
        this.docLinkProvider.resetCustomLinks();
102✔
430

431
        for (let i = 0; i < this.allLogLines.length - 1; i++) {
102✔
UNCOV
432
            let logLine = this.allLogLines[i];
×
UNCOV
433
            if (this.shouldLineBeShown(logLine)) {
×
UNCOV
434
                this.addLogLineToOutput(logLine);
×
435
            }
436
        }
437
    }
438

439
    public markOutput(): void {
440
        this.appendLine(`---------------------- MARK ${this.markCount} ----------------------`, true);
8✔
441
        this.markCount++;
8✔
442
    }
443
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc