@sasjs/cli 6.3.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - Scaffold a new SAS App in seconds (`sasjs create myApp -t jobs`)
18
18
  - Compile / Build SAS Jobs, Services & Macros _locally_ and deploy / execute _remotely_, in isolation from other developers (`sasjs cbd`)
19
19
  - Compile a single Job, Service or Test without touching the rest of the build (`sasjs compile identify --source sasjs/jobs/daily/load.sas`)
20
+ - Write the test coverage report to `sasjsbuild/coverage.json` on every compile, ready for a CI job to publish
20
21
  - No dependency on the server filesystem
21
22
  - Optionally synchronise a local folder onto the server filesystem using `sasjs fs`
22
23
  - Trigger Viya Jobs & Flows (`sasjs job execute` / `sasjs flow execute`)
@@ -1,5 +1,7 @@
1
1
  import { CompileTree, Target } from '@sasjs/utils';
2
2
  import { TestFlow } from '../../../types';
3
+ /** The coverage report `sasjs compile` writes to the build folder. */
4
+ export declare const coverageFileName = "coverage.json";
3
5
  /**
4
6
  * Resolves the relative destination fragment for a compiled test file inside
5
7
  * `sasjsbuild/tests` - either just the file's basename (saveToRoot, used for
@@ -64,7 +64,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
64
64
  return (mod && mod.__esModule) ? mod : { "default": mod };
65
65
  };
66
66
  Object.defineProperty(exports, "__esModule", { value: true });
67
- exports.compileTestFlow = exports.getTestFileDestinationFragment = void 0;
67
+ exports.compileTestFlow = exports.getTestFileDestinationFragment = exports.coverageFileName = void 0;
68
68
  exports.compileTestFile = compileTestFile;
69
69
  exports.copyTestMacroFiles = copyTestMacroFiles;
70
70
  var utils_1 = require("@sasjs/utils");
@@ -75,6 +75,8 @@ var config_1 = require("../../../utils/config");
75
75
  var file_1 = require("../../../utils/file");
76
76
  var _1 = require("./");
77
77
  var getFileName = function (filePath) { return path_1.default.parse(filePath).base; };
78
+ /** The coverage report `sasjs compile` writes to the build folder. */
79
+ exports.coverageFileName = 'coverage.json';
78
80
  /**
79
81
  * Resolves the relative destination fragment for a compiled test file inside
80
82
  * `sasjsbuild/tests` - either just the file's basename (saveToRoot, used for
@@ -232,7 +234,7 @@ var compileTestFlow = function (target) { return __awaiter(void 0, void 0, void
232
234
  }); };
233
235
  exports.compileTestFlow = compileTestFlow;
234
236
  var printTestCoverage = function (testFlow, buildDestinationFolder, target) { return __awaiter(void 0, void 0, void 0, function () {
235
- var toCover, covered, extraTests, serviceFolder, collectCoverage, jobFolder, macroFolders, filter, coverage, notCovered, coveredServices, servicesToCover, notCoveredServices, coveredJobs, jobsToCover, notCoveredJobs, coveredMacros, macrosToCover, notCoveredMacros, calculateCoverage, formatCoverage;
237
+ var toCover, covered, extraTests, serviceFolder, collectCoverage, jobFolder, macroFolders, filter, coveredServices, servicesToCover, coveredJobs, jobsToCover, coveredMacros, macrosToCover, coverage, markCoverage, summarize, formatCoverage, summary, report;
236
238
  var _a, _b, _c;
237
239
  return __generator(this, function (_d) {
238
240
  switch (_d.label) {
@@ -315,74 +317,52 @@ var printTestCoverage = function (testFlow, buildDestinationFolder, target) { re
315
317
  filter = function (files, type) {
316
318
  return files.filter(function (file) { return new RegExp("^".concat(type)).test(file); });
317
319
  };
318
- coverage = {};
319
- notCovered = toCover.filter(function (tc) { return !covered.includes(tc); });
320
320
  coveredServices = filter(covered, types_1.CoverageType.service);
321
321
  servicesToCover = filter(toCover, types_1.CoverageType.service);
322
- notCoveredServices = filter(notCovered, types_1.CoverageType.service);
323
- notCoveredServices.forEach(function (file, i) {
324
- coverage[file] = {
325
- Type: types_1.CoverageType.service,
326
- Coverage: types_1.CoverageState.notCovered
327
- };
328
- });
329
322
  coveredJobs = filter(covered, types_1.CoverageType.job);
330
323
  jobsToCover = filter(toCover, types_1.CoverageType.job);
331
- notCoveredJobs = filter(notCovered, types_1.CoverageType.job);
332
- notCoveredJobs.forEach(function (file, i) {
333
- coverage[file] = {
334
- Type: types_1.CoverageType.job,
335
- Coverage: types_1.CoverageState.notCovered
336
- };
337
- });
338
324
  coveredMacros = filter(covered, types_1.CoverageType.macro);
339
325
  macrosToCover = filter(toCover, types_1.CoverageType.macro);
340
- notCoveredMacros = filter(notCovered, types_1.CoverageType.macro);
341
- if (!notCoveredMacros.length) return [3 /*break*/, 6];
342
- return [4 /*yield*/, (0, utils_1.asyncForEach)(macroFolders, function (macroFolder) { return __awaiter(void 0, void 0, void 0, function () {
343
- var macros, macroTypeRegExp;
344
- return __generator(this, function (_a) {
345
- switch (_a.label) {
346
- case 0: return [4 /*yield*/, (0, utils_1.listFilesAndSubFoldersInFolder)(macroFolder)];
347
- case 1: return [4 /*yield*/, (_a.sent()).filter(function (file) { return !(0, utils_1.isTestFile)(file); })];
348
- case 2:
349
- macros = _a.sent();
350
- macroTypeRegExp = new RegExp("^macros".concat(utils_1.pathSepEscaped));
351
- notCoveredMacros = notCoveredMacros.map(function (macro) {
352
- return macros.includes(macro.replace(macroTypeRegExp, ''))
353
- ? macro.replace(macroTypeRegExp, macroFolder + path_1.default.sep)
354
- : macro;
355
- });
356
- return [2 /*return*/];
357
- }
358
- });
359
- }); })];
360
- case 5:
361
- _d.sent();
362
- _d.label = 6;
363
- case 6:
364
- notCoveredMacros.forEach(function (file, i) {
365
- coverage[file] = {
366
- Type: types_1.CoverageType.macro,
367
- Coverage: types_1.CoverageState.notCovered
368
- };
369
- });
326
+ coverage = {};
327
+ markCoverage = function (type, filesToCover, coveredFiles) {
328
+ return filesToCover.forEach(function (file) {
329
+ coverage[file] = {
330
+ Type: type,
331
+ Coverage: coveredFiles.includes(file)
332
+ ? types_1.CoverageState.covered
333
+ : types_1.CoverageState.notCovered
334
+ };
335
+ });
336
+ };
337
+ markCoverage(types_1.CoverageType.service, servicesToCover, coveredServices);
338
+ markCoverage(types_1.CoverageType.job, jobsToCover, coveredJobs);
339
+ markCoverage(types_1.CoverageType.macro, macrosToCover, coveredMacros);
370
340
  extraTests.forEach(function (file) {
371
341
  coverage[file] = {
372
342
  Type: types_1.CoverageType.test,
373
343
  Coverage: types_1.CoverageState.standalone
374
344
  };
375
345
  });
376
- calculateCoverage = function (covered, from) {
377
- return (Math.round((covered / from) * 100) || 0) + '%';
378
- };
379
- formatCoverage = function (type, covered, toCover) {
346
+ summarize = function (cov, toCov) { return ({
347
+ covered: cov.length,
348
+ total: toCov.length,
349
+ percentage: toCov.length ? Math.round((cov.length / toCov.length) * 100) : 0
350
+ }); };
351
+ formatCoverage = function (type, summary) {
380
352
  var _a;
381
- return toCover.length
382
- ? (_a = process.logger) === null || _a === void 0 ? void 0 : _a.info("".concat(type, " coverage: ").concat(covered.length, "/").concat(toCover.length, " (").concat(chalk_1.default.greenBright(calculateCoverage(covered.length, toCover.length)), ")"))
353
+ return summary.total
354
+ ? (_a = process.logger) === null || _a === void 0 ? void 0 : _a.info("".concat(type, " coverage: ").concat(summary.covered, "/").concat(summary.total, " (").concat(chalk_1.default.greenBright("".concat(summary.percentage, "%")), ")"))
383
355
  : null;
384
356
  };
357
+ summary = {
358
+ services: summarize(coveredServices, servicesToCover),
359
+ jobs: summarize(coveredJobs, jobsToCover),
360
+ macros: summarize(coveredMacros, macrosToCover),
361
+ overall: summarize(covered, toCover)
362
+ };
385
363
  (_a = process.logger) === null || _a === void 0 ? void 0 : _a.info('Test coverage:');
364
+ // The table renders the same map coverage.json serialises, so a reader sees
365
+ // every file - covered rows included - under the key the report uses.
386
366
  (_b = process.logger) === null || _b === void 0 ? void 0 : _b.table(Object.keys(coverage).map(function (key) { return [
387
367
  key,
388
368
  coverage[key].Type,
@@ -390,13 +370,21 @@ var printTestCoverage = function (testFlow, buildDestinationFolder, target) { re
390
370
  ]; }), {
391
371
  head: ['File', 'Type', 'Coverage']
392
372
  });
393
- formatCoverage('Services', coveredServices, servicesToCover);
394
- formatCoverage('Jobs', coveredJobs, jobsToCover);
395
- formatCoverage('Macros', coveredMacros, macrosToCover);
396
- formatCoverage('Overall', covered, toCover);
373
+ formatCoverage('Services', summary.services);
374
+ formatCoverage('Jobs', summary.jobs);
375
+ formatCoverage('Macros', summary.macros);
376
+ formatCoverage('Overall', summary.overall);
397
377
  (_c = process.logger) === null || _c === void 0 ? void 0 : _c.log('');
398
- return [4 /*yield*/, (0, utils_1.createFile)(path_1.default.join(buildDestinationFolder, 'testFlow.json'), JSON.stringify(testFlow, null, 2))];
399
- case 7:
378
+ return [4 /*yield*/, (0, utils_1.createFile)(path_1.default.join(buildDestinationFolder, 'testFlow.json'), JSON.stringify(testFlow, null, 2))
379
+ // The numbers are already computed and rendered above, so the report costs
380
+ // one small file - which lets a CI job publish the figures instead of
381
+ // scraping the log.
382
+ ];
383
+ case 5:
384
+ _d.sent();
385
+ report = { summary: summary, files: coverage };
386
+ return [4 /*yield*/, (0, utils_1.createFile)(path_1.default.join(buildDestinationFolder, exports.coverageFileName), JSON.stringify(report, null, 2))];
387
+ case 6:
400
388
  _d.sent();
401
389
  return [2 /*return*/];
402
390
  }
@@ -51,6 +51,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
51
51
  };
52
52
  Object.defineProperty(exports, "__esModule", { value: true });
53
53
  var __1 = require("../");
54
+ var compileTestFile_1 = require("../compileTestFile");
54
55
  var utils_1 = require("@sasjs/utils");
55
56
  var test_1 = require("../../../../utils/test");
56
57
  var path_1 = __importDefault(require("path"));
@@ -391,6 +392,163 @@ describe('compileTestFile', function () {
391
392
  }
392
393
  });
393
394
  }); });
395
+ it('should write the coverage report to the build folder', function () { return __awaiter(void 0, void 0, void 0, function () {
396
+ var report, _a, _b;
397
+ return __generator(this, function (_c) {
398
+ switch (_c.label) {
399
+ case 0: return [4 /*yield*/, (0, compile_1.compile)(target)];
400
+ case 1:
401
+ _c.sent();
402
+ _b = (_a = JSON).parse;
403
+ return [4 /*yield*/, (0, utils_1.readFile)(path_1.default.join(buildPath, compileTestFile_1.coverageFileName))];
404
+ case 2:
405
+ report = _b.apply(_a, [_c.sent()]);
406
+ expect(report.summary).toEqual({
407
+ services: { covered: 0, total: 4, percentage: 0 },
408
+ jobs: { covered: 0, total: 0, percentage: 0 },
409
+ macros: { covered: 0, total: 0, percentage: 0 },
410
+ overall: { covered: 0, total: 4, percentage: 0 }
411
+ });
412
+ // the same rows the console table lists, standalone tests included
413
+ expect(report.files[['services', 'common', 'appinit.sas'].join('/')]).toEqual({ Type: 'service', Coverage: 'not covered' });
414
+ expect(report.files[['tests', 'jobs', 'jobs', 'testJob.test.sas'].join('/')]).toEqual({ Type: 'test', Coverage: 'standalone' });
415
+ return [2 /*return*/];
416
+ }
417
+ });
418
+ }); });
419
+ it('should key the report by project-relative paths, never build machine paths', function () { return __awaiter(void 0, void 0, void 0, function () {
420
+ var macroPath, tableSpy, report, _a, _b, tableRows;
421
+ return __generator(this, function (_c) {
422
+ switch (_c.label) {
423
+ case 0:
424
+ macroPath = path_1.default.join(sasjsPath, 'macros', 'uncoveredmacro.sas');
425
+ tableSpy = jest
426
+ .spyOn(process.logger, 'table')
427
+ .mockImplementation(function () { return ''; });
428
+ return [4 /*yield*/, (0, utils_1.createFile)(macroPath, '%macro uncoveredmacro(); %mend uncoveredmacro;')];
429
+ case 1:
430
+ _c.sent();
431
+ _c.label = 2;
432
+ case 2:
433
+ _c.trys.push([2, , 5, 7]);
434
+ return [4 /*yield*/, (0, compile_1.compile)(target)];
435
+ case 3:
436
+ _c.sent();
437
+ _b = (_a = JSON).parse;
438
+ return [4 /*yield*/, (0, utils_1.readFile)(path_1.default.join(buildPath, compileTestFile_1.coverageFileName))];
439
+ case 4:
440
+ report = _b.apply(_a, [_c.sent()]);
441
+ // macros are keyed like services and jobs, in the report and in the
442
+ // console table alike - a build machine path in either would make the
443
+ // same project produce different output elsewhere.
444
+ expect(report.files['macros/uncoveredmacro.sas']).toEqual({
445
+ Type: 'macro',
446
+ Coverage: 'not covered'
447
+ });
448
+ expect(Object.keys(report.files).filter(function (key) { return path_1.default.isAbsolute(key); })).toEqual([]);
449
+ expect(report.summary.macros).toEqual({
450
+ covered: 0,
451
+ total: 1,
452
+ percentage: 0
453
+ });
454
+ tableRows = tableSpy.mock.calls[0][0];
455
+ expect(tableRows).toContainEqual([
456
+ 'macros/uncoveredmacro.sas',
457
+ 'macro',
458
+ 'not covered'
459
+ ]);
460
+ expect(tableRows.flat().filter(function (cell) { return path_1.default.isAbsolute(cell); })).toEqual([]);
461
+ return [3 /*break*/, 7];
462
+ case 5: return [4 /*yield*/, (0, utils_1.deleteFile)(macroPath)];
463
+ case 6:
464
+ _c.sent();
465
+ return [7 /*endfinally*/];
466
+ case 7: return [2 /*return*/];
467
+ }
468
+ });
469
+ }); });
470
+ it('should mark covered files in the report, not just the gaps', function () { return __awaiter(void 0, void 0, void 0, function () {
471
+ var macroPath, macroTestPath, tableSpy, report, _a, _b, tableRows;
472
+ return __generator(this, function (_c) {
473
+ switch (_c.label) {
474
+ case 0:
475
+ macroPath = path_1.default.join(sasjsPath, 'macros', 'coveredmacro.sas');
476
+ macroTestPath = path_1.default.join(sasjsPath, 'macros', 'coveredmacro.test.sas');
477
+ tableSpy = jest
478
+ .spyOn(process.logger, 'table')
479
+ .mockImplementation(function () { return ''; });
480
+ return [4 /*yield*/, (0, utils_1.createFile)(macroPath, '%macro coveredmacro(); %mend coveredmacro;')];
481
+ case 1:
482
+ _c.sent();
483
+ return [4 /*yield*/, (0, utils_1.createFile)(macroTestPath, '%put testing coveredmacro;')];
484
+ case 2:
485
+ _c.sent();
486
+ _c.label = 3;
487
+ case 3:
488
+ _c.trys.push([3, , 6, 9]);
489
+ return [4 /*yield*/, (0, compile_1.compile)(target)];
490
+ case 4:
491
+ _c.sent();
492
+ _b = (_a = JSON).parse;
493
+ return [4 /*yield*/, (0, utils_1.readFile)(path_1.default.join(buildPath, compileTestFile_1.coverageFileName))];
494
+ case 5:
495
+ report = _b.apply(_a, [_c.sent()]);
496
+ expect(report.files['macros/coveredmacro.sas']).toEqual({
497
+ Type: 'macro',
498
+ Coverage: 'covered'
499
+ });
500
+ expect(report.summary.macros).toEqual({
501
+ covered: 1,
502
+ total: 1,
503
+ percentage: 100
504
+ });
505
+ tableRows = tableSpy.mock.calls[0][0];
506
+ expect(tableRows).toContainEqual([
507
+ 'macros/coveredmacro.sas',
508
+ 'macro',
509
+ 'covered'
510
+ ]);
511
+ return [3 /*break*/, 9];
512
+ case 6: return [4 /*yield*/, (0, utils_1.deleteFile)(macroPath)];
513
+ case 7:
514
+ _c.sent();
515
+ return [4 /*yield*/, (0, utils_1.deleteFile)(macroTestPath)];
516
+ case 8:
517
+ _c.sent();
518
+ return [7 /*endfinally*/];
519
+ case 9: return [2 /*return*/];
520
+ }
521
+ });
522
+ }); });
523
+ it('should not write a coverage report when no tests were compiled', function () { return __awaiter(void 0, void 0, void 0, function () {
524
+ var coveragePath, testFlow;
525
+ return __generator(this, function (_a) {
526
+ switch (_a.label) {
527
+ case 0: return [4 /*yield*/, (0, compile_1.compile)(target)];
528
+ case 1:
529
+ _a.sent();
530
+ coveragePath = path_1.default.join(buildPath, compileTestFile_1.coverageFileName);
531
+ // remove the report the compile wrote, so the assertion below is about
532
+ // the no-tests path and not about a file left over from it
533
+ return [4 /*yield*/, (0, utils_1.deleteFile)(coveragePath)];
534
+ case 2:
535
+ // remove the report the compile wrote, so the assertion below is about
536
+ // the no-tests path and not about a file left over from it
537
+ _a.sent();
538
+ return [4 /*yield*/, (0, utils_1.deleteFolder)(path_1.default.join(buildPath, 'tests'))];
539
+ case 3:
540
+ _a.sent();
541
+ return [4 /*yield*/, (0, __1.compileTestFlow)(target)];
542
+ case 4:
543
+ testFlow = _a.sent();
544
+ expect(testFlow).toBeUndefined();
545
+ return [4 /*yield*/, expect((0, utils_1.fileExists)(coveragePath)).resolves.toEqual(false)];
546
+ case 5:
547
+ _a.sent();
548
+ return [2 /*return*/];
549
+ }
550
+ });
551
+ }); });
394
552
  it('should log a warning is testFolders was used in root or target configuration', function () { return __awaiter(void 0, void 0, void 0, function () {
395
553
  var testTarget, testConfig, expectedWarn;
396
554
  return __generator(this, function (_a) {
@@ -97,6 +97,7 @@ function printHelpText() {
97
97
  "compiles the services specified in the ".concat(chalk_1.default.cyanBright('serviceFolders'), " property of the 'serviceConfig' in your target and common configuration."),
98
98
  "A single file is compiled by naming its type as a sub command - ".concat(chalk_1.default.cyanBright('job'), ", ").concat(chalk_1.default.cyanBright('service'), ", ").concat(chalk_1.default.cyanBright('test'), " or ").concat(chalk_1.default.cyanBright('identify'), ", which works the type out from the file itself - together with ").concat(chalk_1.default.cyanBright('--source'), " (or ").concat(chalk_1.default.cyanBright('-s'), ")."),
99
99
  "The compiled file is written to the ".concat(chalk_1.default.cyanBright('jobs'), " or ").concat(chalk_1.default.cyanBright('services'), " folder of sasjsbuild for its type, or below ").concat(chalk_1.default.cyanBright('--output'), " (or ").concat(chalk_1.default.cyanBright('-o'), ") when given."),
100
+ "The test coverage report is written to ".concat(chalk_1.default.cyanBright('coverage.json'), " in the build folder alongside ").concat(chalk_1.default.cyanBright('testFlow.json'), "."),
100
101
  "command example: sasjs compile identify --source sasjs/jobs/daily/load.sas --target myTarget"
101
102
  ]
102
103
  },
@@ -2,6 +2,6 @@ export { CommandBase } from './command/commandBase';
2
2
  export { File, FilePath } from './file';
3
3
  export { Folder } from './folder';
4
4
  export { TargetScope } from './targetScope';
5
- export { TestFlow, Coverage, CoverageType, CoverageState, TestResults, TestResultStatus, TestDescription, TestResult, TestResultDescription, TestResultCsv } from './testing';
5
+ export { TestFlow, Coverage, CoverageReport, CoverageSummary, CoverageType, CoverageState, TestResults, TestResultStatus, TestDescription, TestResult, TestResultDescription, TestResultCsv } from './testing';
6
6
  export { Flow, FlowWave, FlowWaveJob } from './flow';
7
7
  export * from './log';
@@ -12,6 +12,7 @@ export declare enum CoverageType {
12
12
  }
13
13
  export declare enum CoverageState {
14
14
  notCovered = "not covered",
15
+ covered = "covered",
15
16
  standalone = "standalone"
16
17
  }
17
18
  export interface Coverage {
@@ -20,6 +21,38 @@ export interface Coverage {
20
21
  Coverage: CoverageState;
21
22
  };
22
23
  }
24
+ /**
25
+ * Totals for one group of files (services, jobs, macros or all of them).
26
+ * `percentage` is rounded to a whole number, matching the figure printed in
27
+ * the console summary.
28
+ */
29
+ export interface CoverageSummary {
30
+ covered: number;
31
+ total: number;
32
+ percentage: number;
33
+ }
34
+ /**
35
+ * The content of the coverage report `sasjs compile` writes to
36
+ * `coverage.json` in the build folder.
37
+ *
38
+ * `summary` carries the same totals as the console output. `files` is a
39
+ * complete per-file map - every service, job and macro the compile found,
40
+ * each marked `covered` or `not covered`, plus standalone tests - keyed by
41
+ * project-relative path (`services/common/appinit.sas`,
42
+ * `macros/mymacro.sas`), never by a path from the machine that compiled it.
43
+ *
44
+ * The console table renders this same map, so the rows a reader sees and the
45
+ * keys a consumer reads are the same, covered rows included.
46
+ */
47
+ export interface CoverageReport {
48
+ summary: {
49
+ services: CoverageSummary;
50
+ jobs: CoverageSummary;
51
+ macros: CoverageSummary;
52
+ overall: CoverageSummary;
53
+ };
54
+ files: Coverage;
55
+ }
23
56
  export interface TestResultDescription {
24
57
  TEST_DESCRIPTION: string;
25
58
  TEST_RESULT: TestResultStatus.pass | TestResultStatus.fail;
@@ -11,6 +11,7 @@ var CoverageType;
11
11
  var CoverageState;
12
12
  (function (CoverageState) {
13
13
  CoverageState["notCovered"] = "not covered";
14
+ CoverageState["covered"] = "covered";
14
15
  CoverageState["standalone"] = "standalone";
15
16
  })(CoverageState || (exports.CoverageState = CoverageState = {}));
16
17
  var TestResultStatus;
package/package.json CHANGED
@@ -126,5 +126,5 @@
126
126
  "peerDependencies": {
127
127
  "@sasjs/lint": "^4.4.0"
128
128
  },
129
- "version": "6.3.0"
129
+ "version": "6.4.0"
130
130
  }