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

ckeditor / ckeditor5-dev / be4e2eec-d0f8-4353-be0d-2b38c57bacb5

12 Aug 2025 08:19AM UTC coverage: 86.735% (-0.01%) from 86.745%
be4e2eec-d0f8-4353-be0d-2b38c57bacb5

Pull #1188

circleci

przemyslaw-zan
Added changeset.
Pull Request #1188: Changes related to cke root package migration

1985 of 2065 branches covered (96.13%)

Branch coverage included in aggregate %.

64 of 65 new or added lines in 2 files covered. (98.46%)

75 existing lines in 1 file now uncovered.

10576 of 12417 relevant lines covered (85.17%)

27.18 hits per line

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

0.0
/packages/ckeditor5-dev-dependency-checker/lib/checkdependencies.js
1
/**
×
2
 * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
×
3
 * For licensing, see LICENSE.md.
×
4
 */
×
5

6
import fs from 'fs-extra';
×
7
import upath from 'upath';
×
8
import { globSync } from 'glob';
×
9
import depCheck from 'depcheck';
×
10
import chalk from 'chalk';
×
11
import { parseAsync } from 'oxc-parser';
×
12
import { walk } from 'oxc-walker';
×
13

14
/**
×
15
 * Checks dependencies sequentially in all provided packages.
×
16
 *
×
17
 * @param {Set.<string>} packagePaths Relative paths to packages.
×
18
 * @param {object} options Options.
×
19
 * @param {boolean} [options.quiet=false] Whether to inform about the progress.
×
20
 * @returns {Promise.<boolean>} Resolves a promise with a flag informing whether detected an error.
×
21
 */
×
22
export default async function checkDependencies( packagePaths, options ) {
×
23
        let foundError = false;
×
24

25
        for ( const packagePath of packagePaths ) {
×
26
                const isSuccess = await checkDependenciesInPackage( packagePath, {
×
27
                        quiet: options.quiet
×
UNCOV
28
                } );
×
29

30
                // Mark result of this script execution as invalid.
×
31
                if ( !isSuccess ) {
×
32
                        foundError = true;
×
UNCOV
33
                }
×
34
        }
×
35

36
        return Promise.resolve( foundError );
×
37
}
×
38

UNCOV
39
/**
×
40
 * Checks dependencies in provided package. If the folder does not contain a package.json file the function quits with success.
×
41
 *
×
UNCOV
42
 * @param {string} packagePath Relative path to package.
×
43
 * @param {object} options Options.
×
44
 * @param {boolean} [options.quiet=false] Whether to inform about the progress.
×
45
 * @returns {Promise.<boolean>} The result of checking the dependencies in the package: true = no errors found.
×
46
 */
×
47
async function checkDependenciesInPackage( packagePath, options ) {
×
48
        const packageAbsolutePath = upath.resolve( packagePath );
×
49
        const packageJsonPath = upath.join( packageAbsolutePath, 'package.json' );
×
50

51
        if ( !fs.existsSync( packageJsonPath ) ) {
×
52
                console.log( `⚠️  Missing package.json file in ${ chalk.bold( packagePath ) }, skipping...\n` );
×
53

UNCOV
54
                return true;
×
55
        }
×
56

UNCOV
57
        const packageJson = await fs.readJson( packageJsonPath );
×
58

59
        const missingCSSFiles = [];
×
UNCOV
60
        const onMissingCSSFile = file => missingCSSFiles.push( file );
×
61

UNCOV
62
        const depCheckOptions = {
×
63
                // We need to add all values manually because if we modify it, the rest is being lost.
×
64
                parsers: {
×
UNCOV
65
                        '**/*.css': filePath => parsePostCSS( filePath, onMissingCSSFile ),
×
66
                        '**/*.cjs': depCheck.parser.es6,
×
67
                        '**/*.mjs': filePath => parseModule( filePath, packageJson ),
×
68
                        '**/*.js': filePath => parseModule( filePath, packageJson ),
×
69
                        '**/*.jsx': filePath => parseModule( filePath, packageJson ),
×
70
                        '**/*.ts': filePath => parseModule( filePath, packageJson ),
×
71
                        '**/*.vue': depCheck.parser.vue
×
72
                },
×
73
                ignorePatterns: [ 'docs', 'build', 'dist/browser' ],
×
74
                ignoreMatches: [ 'eslint*', 'webpack*', 'husky', 'lint-staged' ]
×
75
        };
×
76

77
        const depcheckIgnore = Array.isArray( packageJson.depcheckIgnore ) ? packageJson.depcheckIgnore : [];
×
78

79
        depCheckOptions.ignoreMatches.push( ...depcheckIgnore );
×
80

81
        if ( !options.quiet ) {
×
UNCOV
82
                console.log( `🔎 Checking dependencies in ${ chalk.bold( packageJson.name ) }...` );
×
83
        }
×
84

UNCOV
85
        const result = await depCheck( packageAbsolutePath, depCheckOptions );
×
UNCOV
86
        const missingPackages = groupMissingPackages( result.missing, packageJson.name );
×
87

88
        const misplacedOptions = {
×
89
                dependencies: packageJson.dependencies,
×
UNCOV
90
                devDependencies: packageJson.devDependencies,
×
91
                dependenciesToCheck: result.using,
×
92
                dependenciesToIgnore: depcheckIgnore
×
UNCOV
93
        };
×
94

95
        const errors = [
×
96
                // Invalid itself imports.
×
97
                getInvalidItselfImports( packageAbsolutePath )
×
98
                        .map( entry => '- ' + entry )
×
99
                        .join( '\n' ),
×
100

101
                // Missing dependencies.
×
102
                missingPackages.dependencies
×
103
                        .map( entry => '- ' + entry )
×
104
                        .join( '\n' ),
×
105

UNCOV
106
                // Missing devDependencies.
×
107
                missingPackages.devDependencies
×
108
                        .map( entry => '- ' + entry )
×
109
                        .join( '\n' ),
×
110

UNCOV
111
                // Unused dependencies.
×
112
                result.dependencies
×
113
                        .map( entry => '- ' + entry )
×
114
                        .join( '\n' ),
×
115

UNCOV
116
                // Unused devDependencies.
×
117
                result.devDependencies
×
118
                        .map( entry => '- ' + entry )
×
119
                        .join( '\n' ),
×
120

UNCOV
121
                // Relative CSS imports (files do not exist).
×
122
                missingCSSFiles
×
123
                        .map( entry => {
×
124
                                return `- "${ entry.file }" imports "${ entry.import }"`;
×
125
                        } )
×
UNCOV
126
                        .join( '\n' ),
×
127

128
                // Duplicated `dependencies` and `devDependencies`.
×
129
                findDuplicatedDependencies( packageJson.dependencies, packageJson.devDependencies )
×
130
                        .map( entry => '- ' + entry )
×
131
                        .join( '\n' ),
×
132

UNCOV
133
                // Misplaced `dependencies` or `devDependencies`.
×
134
                // Checks whether any package, which is already listed in the `dependencies` or `devDependencies`,
×
135
                // should belong to that list.
×
136
                findMisplacedDependencies( misplacedOptions )
×
137
                        .reduce( ( result, group ) => {
×
UNCOV
138
                                return result + '\n' +
×
139
                                        group.description + '\n' +
×
140
                                        group.packageNames.map( entry => '- ' + entry ).join( '\n' ) + '\n';
×
141
                        }, '' )
×
142
        ];
×
143

144
        const hasErrors = errors.some( error => !!error );
×
145

146
        if ( !hasErrors ) {
×
147
                if ( !options.quiet ) {
×
148
                        console.log( chalk.green.bold( '✨ All dependencies are defined correctly.\n' ) );
×
UNCOV
149
                }
×
150

UNCOV
151
                return true;
×
152
        }
×
153

154
        console.log( chalk.red.bold( `🔥 Found some issue with dependencies in ${ chalk.bold( packageJson.name ) }.\n` ) );
×
155

UNCOV
156
        showErrors( errors );
×
157

158
        return false;
×
UNCOV
159
}
×
160

UNCOV
161
/**
×
162
 * Returns an array that contains list of files that import modules using full package name instead of relative path.
×
UNCOV
163
 *
×
164
 * @param repositoryPath An absolute path to the directory which should be checked.
×
165
 * @returns {Array.<string>}
×
UNCOV
166
 */
×
167
function getInvalidItselfImports( repositoryPath ) {
×
168
        const packageJson = fs.readJsonSync( upath.join( repositoryPath, 'package.json' ) );
×
169
        const globPattern = upath.join( repositoryPath, '@(src|tests)/**/*.js' );
×
170
        const invalidImportsItself = new Set();
×
171

172
        for ( const filePath of globSync( globPattern ) ) {
×
173
                const fileContent = fs.readFileSync( filePath, 'utf-8' );
×
174
                const matchedImports = fileContent.match( /^import[^;]+from '(@ckeditor\/[^/]+)[^']+';/mg );
×
175

176
                if ( !matchedImports ) {
×
UNCOV
177
                        continue;
×
178
                }
×
179

180
                matchedImports
×
UNCOV
181
                        .map( importLine => importLine.match( /(@ckeditor\/[^/]+)/ ) )
×
182
                        .filter( matchedImport => !!matchedImport )
×
183
                        .forEach( matchedImport => {
×
184
                                // Current package should use relative links to itself.
×
UNCOV
185
                                if ( packageJson.name === matchedImport[ 1 ] ) {
×
186
                                        invalidImportsItself.add( filePath.replace( repositoryPath + '/', '' ) );
×
187
                                }
×
188
                        } );
×
189
        }
×
190

191
        return [ ...invalidImportsItself ].sort();
×
192
}
×
193

194
/**
×
195
 * Groups missing dependencies returned by `depcheck` as `dependencies` or `devDependencies`.
×
UNCOV
196
 *
×
197
 * @param {object} missingPackages The `missing` value from object returned by `depcheck`.
×
198
 * @param {string} currentPackage Name of current package.
×
UNCOV
199
 * @returns {Object.<string, Array.<string>>}
×
200
 */
×
201
function groupMissingPackages( missingPackages, currentPackage ) {
×
202
        delete missingPackages[ currentPackage ];
×
203

204
        const dependencies = [];
×
205
        const devDependencies = [];
×
206

207
        for ( const packageName of Object.keys( missingPackages ) ) {
×
208
                const absolutePaths = missingPackages[ packageName ];
×
209

210
                if ( isProductionDependency( absolutePaths ) ) {
×
211
                        dependencies.push( packageName );
×
UNCOV
212
                } else {
×
213
                        devDependencies.push( packageName );
×
214
                }
×
UNCOV
215
        }
×
216

217
        return { dependencies, devDependencies };
×
218
}
×
219

220
/**
×
221
 * Checks whether all packages that have been imported by the CSS file are defined in `package.json` as `dependencies`.
×
UNCOV
222
 * Returned array contains list of used packages.
×
223
 *
×
224
 * @param {string} filePath An absolute path to the checking file.
×
UNCOV
225
 * @param {function} onMissingCSSFile Error handler called when a CSS file is not found.
×
226
 * @returns {Array.<string>|undefined}
×
227
 */
×
228
function parsePostCSS( filePath, onMissingCSSFile ) {
×
229
        const fileContent = fs.readFileSync( filePath, 'utf-8' );
×
230
        const matchedImports = fileContent.match( /^@import "[^"]+";/mg );
×
231

232
        if ( !matchedImports ) {
×
233
                return;
×
234
        }
×
235

236
        const usedPackages = new Set();
×
237

238
        matchedImports
×
239
                .map( importLine => {
×
240
                        const importedFile = importLine.match( /"([^"]+)"/ )[ 1 ];
×
241

242
                        // Scoped package.
×
UNCOV
243
                        // @import "@foo/bar/...";
×
244
                        // @import "@foo/bar"; and its package.json: { "main": "foo-bar.css" }
×
245
                        if ( importedFile.startsWith( '@' ) ) {
×
246
                                return {
×
UNCOV
247
                                        type: 'package',
×
248
                                        name: importedFile.split( '/' ).slice( 0, 2 ).join( '/' )
×
249
                                };
×
250
                        }
×
251

252
                        // Relative import.
×
253
                        // @import "./file.css"; or @import "../file.css";
×
254
                        if ( importedFile.startsWith( './' ) || importedFile.startsWith( '../' ) ) {
×
255
                                return {
×
256
                                        type: 'file',
×
UNCOV
257
                                        path: importedFile
×
258
                                };
×
259
                        }
×
260

261
                        // Non-scoped package.
×
262
                        return {
×
263
                                type: 'package',
×
264
                                name: importedFile.split( '/' )[ 0 ]
×
265
                        };
×
UNCOV
266
                } )
×
267
                .forEach( importDetails => {
×
268
                        // If checked file imports another file, checks whether imported file exists.
×
269
                        if ( importDetails.type == 'file' ) {
×
270
                                const fileToImport = upath.resolve( filePath, '..', importDetails.path );
×
271

272
                                if ( !fs.existsSync( fileToImport ) ) {
×
273
                                        onMissingCSSFile( {
×
274
                                                file: filePath,
×
275
                                                import: importDetails.path
×
276
                                        } );
×
UNCOV
277
                                }
×
278

279
                                return;
×
280
                        }
×
281

282
                        usedPackages.add( importDetails.name );
×
283
                } );
×
284

285
        return [ ...usedPackages ].sort();
×
286
}
×
287

288
/**
×
289
 * Returns all dependencies found in the file, including those from `import.meta.resolve`.
×
UNCOV
290
 *
×
291
 * @param {string} path
×
292
 * @param {Record<string, unknown>} packageJson
×
UNCOV
293
 * @returns {Array<string>}
×
294
 */
×
295
async function parseModule( path, packageJson ) {
×
296
        const content = await fs.promises.readFile( path, 'utf-8' );
×
297
        const { errors, module, program } = await parseAsync( path, content );
×
298

299
        if ( errors.length || !module.hasModuleSyntax ) {
×
300
                // Use native `depcheck` parser if there was an error or if the file content is not ESM.
×
301
                return depCheck.parser.es6( path );
×
302
        }
×
303

UNCOV
304
        const dependencies = Object.keys( {
×
305
                ...packageJson.dependencies,
×
306
                ...packageJson.devDependencies
×
307
        } );
×
308

UNCOV
309
        const deps = [
×
310
                // Get all static import paths: `import {} from 'package-name'`.
×
311
                ...module.staticImports.map( statement => statement.moduleRequest.value ),
×
312

313
                // Get all static export paths: `export {} from 'package-name'`.
×
UNCOV
314
                ...module.staticExports
×
315
                        .flatMap( staticExport => staticExport.entries.map( entry => entry.moduleRequest?.value ) )
×
316
                        .filter( Boolean )
×
317
        ];
×
318

319
        // Add paths passed to `import.meta.resolve` to the list of dependencies.
×
320
        // This only works if the passed path is a string literal, not a variable.
×
321
        walk( program, {
×
322
                enter( node, parent ) {
×
323
                        if (
×
UNCOV
324
                                node.type === 'MemberExpression' &&
×
325
                                node.object.type === 'MetaProperty' &&
×
326
                                node.property.name === 'resolve' &&
×
327
                                parent.arguments[ 0 ].type === 'Literal'
×
328
                        ) {
×
329
                                deps.push( parent.arguments[ 0 ].value );
×
330
                        }
×
331
                }
×
332
        } );
×
333

334
        return deps
×
335
                // Remove relative paths.
×
336
                .filter( path => !path.startsWith( '.' ) )
×
337

338
                /**
×
UNCOV
339
                 * Get only package names:
×
340
                 *  - `packageName/some/path` -> `packageName`.
×
341
                 *  - `@scope/packageName/some/path` -> `@scope/packageName`.
×
342
                 */
×
UNCOV
343
                .map( path => {
×
344
                        const parts = path.split( '/' );
×
345

346
                        return path.startsWith( '@' ) ? parts.slice( 0, 2 ).join( '/' ) : parts[ 0 ];
×
347
                } )
×
348

349
                /**
×
350
                 * Add `@types` dependencies if they are defined in `package.json`:
×
UNCOV
351
                 *  - `package-name` -> `@types/package-name`.
×
352
                 *  - `@scope/package-name` -> `@types/scope__package-name`.
×
353
                 */
×
UNCOV
354
                .reduce( ( acc, dependency ) => {
×
355
                        const types = dependency.startsWith( '@' ) ?
×
356
                                `@types/${ dependency.replace( '@', '' ).replace( '/', '__' ) }` :
×
357
                                `@types/${ dependency }`;
×
358

359
                        if ( dependencies.includes( types ) ) {
×
360
                                acc.push( types );
×
361
                        }
×
362

363
                        acc.push( dependency );
×
364

365
                        return acc;
×
366
                }, [] )
×
367

UNCOV
368
                // Remove duplicates.
×
369
                .filter( ( value, index, self ) => self.indexOf( value ) === index )
×
370

371
                // Sort the result.
×
372
                .sort();
×
UNCOV
373
}
×
374

375
/**
×
UNCOV
376
 * Checks whether packages specified as `devDependencies` are not duplicated with items defined as `dependencies`.
×
377
 *
×
378
 * @see https://github.com/ckeditor/ckeditor5/issues/7706#issuecomment-665569410
×
379
 * @param {object|undefined} dependencies
×
UNCOV
380
 * @param {object|undefined} devDependencies
×
381
 * @returns {Array.<string>}
×
382
 */
×
383
function findDuplicatedDependencies( dependencies, devDependencies ) {
×
384
        const deps = Object.keys( dependencies || {} );
×
385
        const devDeps = Object.keys( devDependencies || {} );
×
386

387
        if ( !deps.length || !devDeps.length ) {
×
388
                return [];
×
389
        }
×
390

391
        const duplicatedPackages = new Set();
×
392

393
        for ( const packageName of deps ) {
×
394
                if ( devDeps.includes( packageName ) ) {
×
395
                        duplicatedPackages.add( packageName );
×
UNCOV
396
                }
×
397
        }
×
398

399
        return [ ...duplicatedPackages ].sort();
×
400
}
×
401

402
/**
×
403
 * Checks whether all packages, which are already listed in the `dependencies` or `devDependencies`, should belong to that list.
×
UNCOV
404
 * The `devDependencies` list should contain packages, which are not used in the source. Otherwise, a given package should be
×
405
 * added to the `dependencies` list. This function does not check missing dependencies, which is covered elsewhere, but it only
×
406
 * verifies wrongly placed ones.
×
UNCOV
407
 *
×
408
 * @see https://github.com/ckeditor/ckeditor5/issues/8817#issuecomment-759353134
×
409
 * @param {object|undefined} options.dependencies Defined dependencies from package.json.
×
410
 * @param {object|undefined} options.devDependencies Defined development dependencies from package.json.
×
411
 * @param {object} options.dependenciesToCheck All dependencies that have been found and files where they are used.
×
412
 * @param {Array.<string>} options.dependenciesToIgnore An array of package names that should not be checked.
×
413
 * @returns {Array.<object>} Misplaced packages. Each array item is an object containing
×
414
 * the `description` string and `packageNames` array of strings.
×
415
 */
×
416
function findMisplacedDependencies( options ) {
×
417
        const { dependencies, devDependencies, dependenciesToCheck, dependenciesToIgnore } = options;
×
418
        const deps = Object.keys( dependencies || {} );
×
419
        const devDeps = Object.keys( devDependencies || {} );
×
420

421
        const misplacedPackages = {
×
422
                missingInDependencies: {
×
423
                        description: 'The following packages are used in the source and should be moved to `dependencies`',
×
424
                        packageNames: new Set()
×
425
                },
×
UNCOV
426
                missingInDevDependencies: {
×
427
                        description: 'The following packages are not used in the source and should be moved to `devDependencies`',
×
428
                        packageNames: new Set()
×
429
                }
×
430
        };
×
431

432
        for ( const [ packageName, absolutePaths ] of Object.entries( dependenciesToCheck ) ) {
×
433
                if ( dependenciesToIgnore.includes( packageName ) ) {
×
434
                        continue;
×
435
                }
×
436

UNCOV
437
                const isProdDep = isProductionDependency( absolutePaths );
×
438
                const isMissingInDependencies = isProdDep && !deps.includes( packageName ) && devDeps.includes( packageName );
×
439
                const isMissingInDevDependencies = !isProdDep && deps.includes( packageName ) && !devDeps.includes( packageName );
×
440

441
                if ( isMissingInDependencies ) {
×
UNCOV
442
                        misplacedPackages.missingInDependencies.packageNames.add( packageName );
×
443
                }
×
444

445
                if ( isMissingInDevDependencies ) {
×
UNCOV
446
                        misplacedPackages.missingInDevDependencies.packageNames.add( packageName );
×
447
                }
×
448
        }
×
449

UNCOV
450
        return Object
×
451
                .values( misplacedPackages )
×
452
                .filter( item => item.packageNames.size > 0 )
×
453
                .map( item => ( {
×
454
                        description: chalk.gray( item.description ),
×
UNCOV
455
                        packageNames: [ ...item.packageNames ].sort()
×
456
                } ) );
×
457
}
×
458

459
/**
×
460
 * These folders contain code that will be shipped to npm and run in the final projects.
×
461
 * This means that all dependencies used in these folders are production dependencies.
×
462
 */
×
463
const foldersContainingProductionCode = [
×
UNCOV
464
        /**
×
465
         * These folders contain the source code of the packages.
×
466
         */
×
467
        /[/\\]bin[/\\]/,
×
468
        /[/\\]src[/\\]/,
×
469
        /[/\\]lib[/\\]/,
×
470
        /[/\\]theme[/\\]/,
×
471

472
        /**
×
473
         * This folder contains the compiled code of the packages. Most of this code is the same
×
474
         * as the source, but during the build process some of the imports are replaced with those
×
475
         * compatible with the "new installation methods", which may use different dependencies.
×
476
         *
×
UNCOV
477
         * For example, the `ckeditor5/src/core.js` import is replaced with `@ckeditor/ckeditor5-core/dist/index.js`.
×
478
         *                   ^^^^^^^^^                                       ^^^^^^^^^^^^^^^^^^^^^^^^
×
479
         */
×
480
        /[/\\]dist[/\\]/
×
481
];
×
482

483
/**
×
484
 * Checks if a given package is a production dependency, i.e., it's used in build files or their typings.
×
485
 *
×
486
 * @param {Array.<string>} paths Files where a given package has been imported.
×
487
 * @returns {boolean}
×
UNCOV
488
 */
×
489
function isProductionDependency( paths ) {
×
490
        return paths.some(
×
491
                path => foldersContainingProductionCode.some( folder => path.match( folder ) )
×
492
        );
×
493
}
×
494

495
/**
×
496
 * Displays all found errors.
×
497
 *
×
498
 * @param {Array.<string>} data Collection of errors.
×
499
 */
×
UNCOV
500
function showErrors( data ) {
×
501
        if ( data[ 0 ] ) {
×
502
                console.log( chalk.red( '❌ Invalid itself imports found in:' ) );
×
503
                console.log( data[ 0 ] + '\n' );
×
504
                console.log( chalk.gray( 'Imports from local package must always use relative path.\n' ) );
×
505
        }
×
506

507
        if ( data[ 1 ] ) {
×
508
                console.log( chalk.red( '❌ Missing dependencies:' ) );
×
509
                console.log( data[ 1 ] + '\n' );
×
510
        }
×
511

UNCOV
512
        if ( data[ 2 ] ) {
×
513
                console.log( chalk.red( '❌ Missing devDependencies:' ) );
×
514
                console.log( data[ 2 ] + '\n' );
×
515
        }
×
516

UNCOV
517
        if ( data[ 3 ] ) {
×
518
                console.log( chalk.red( '❌ Unused dependencies:' ) );
×
519
                console.log( data[ 3 ] + '\n' );
×
520
        }
×
521

UNCOV
522
        if ( data[ 4 ] ) {
×
523
                console.log( chalk.red( '❌ Unused devDependencies:' ) );
×
524
                console.log( data[ 4 ] + '\n' );
×
525
        }
×
526

UNCOV
527
        if ( data[ 5 ] ) {
×
528
                console.log( chalk.red( '❌ Importing CSS files that do not exist:' ) );
×
529
                console.log( data[ 5 ] + '\n' );
×
530
        }
×
531

UNCOV
532
        if ( data[ 6 ] ) {
×
533
                console.log( chalk.red( '❌ Duplicated `dependencies` and `devDependencies`:' ) );
×
534
                console.log( data[ 6 ] + '\n' );
×
535
        }
×
536

UNCOV
537
        if ( data[ 7 ] ) {
×
538
                console.log( chalk.red( '❌ Misplaced dependencies (`dependencies` or `devDependencies`):' ) );
×
539
                console.log( data[ 7 ] + '\n' );
×
540
        }
×
541
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc