@sasjs/cli 6.2.7 → 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`)
@@ -68,15 +68,17 @@ var syntax = 'auth [subCommand] [options]';
68
68
  var usage = 'sasjs auth login --target <target-name> | sasjs auth [options]';
69
69
  var description = "Authenticates against the specified target.\n" +
70
70
  "With the 'login' subCommand, authentication is performed with a SAS username and password " +
71
- "(no OAuth client/secret required, SASVIYA targets only) and the resulting tokens are saved.\n" +
71
+ "(no OAuth client/secret required) and the resulting tokens are saved. " +
72
+ "Supported on SASVIYA targets, and on SASJS targets, where the server's own local login is used.\n" +
72
73
  "Without a subCommand, behaves like 'sasjs add cred' (client/secret based authentication).\n" +
73
74
  "\n" +
74
75
  "Non-interactive use (CI/agents): set SAS_USERNAME and SAS_PASSWORD environment variables, " +
75
- "or pipe the password and use --password-stdin.";
76
+ "or pipe the password and use --password-stdin. On a SASJS target, set SASJS_CLIENT to the " +
77
+ "registered client id when the account is not an administrator.";
76
78
  var examples = [
77
79
  {
78
80
  command: 'sasjs auth login --target <target-name>',
79
- description: 'Logs in with SAS username/password (no client/secret required).'
81
+ description: 'Logs in with SAS username/password (no client/secret required) on a SASVIYA or SASJS target.'
80
82
  },
81
83
  {
82
84
  command: 'sasjs auth login -t <target-name>',
@@ -1,11 +1,17 @@
1
1
  import { Target } from '@sasjs/utils';
2
2
  /**
3
- * Authenticates against a SASVIYA target using a SAS username and password
4
- * (OAuth2 resource owner password grant against the built-in, secret-less
5
- * `sas.cli` client) and persists the resulting token pair. This enables all
3
+ * Authenticates against a SASVIYA or SASJS target using a SAS username and
4
+ * password, and persists the resulting token pair. This enables all
6
5
  * authenticated commands (`sasjs run`, `sasjs deploy`, etc.) on estates where
7
6
  * no administrator-registered OAuth client/secret is available.
8
7
  *
8
+ * The two server types issue tokens differently: SASVIYA uses the OAuth2
9
+ * resource owner password grant against the built-in, secret-less `sas.cli`
10
+ * client, while SASJS has no password grant and is authenticated through the
11
+ * server's own local login (@see getTokensWithSasjsLogin). Everything else -
12
+ * how the credentials are obtained, and the rule that nothing is prompted for
13
+ * when there is no TTY - is shared by both.
14
+ *
9
15
  * Credentials are resolved in this order (highest precedence first):
10
16
  *
11
17
  * Username: SAS_USERNAME env var > interactive prompt (TTY only)
@@ -139,12 +139,18 @@ var readPasswordFromStdin = function () { return __awaiter(void 0, void 0, void
139
139
  });
140
140
  }); };
141
141
  /**
142
- * Authenticates against a SASVIYA target using a SAS username and password
143
- * (OAuth2 resource owner password grant against the built-in, secret-less
144
- * `sas.cli` client) and persists the resulting token pair. This enables all
142
+ * Authenticates against a SASVIYA or SASJS target using a SAS username and
143
+ * password, and persists the resulting token pair. This enables all
145
144
  * authenticated commands (`sasjs run`, `sasjs deploy`, etc.) on estates where
146
145
  * no administrator-registered OAuth client/secret is available.
147
146
  *
147
+ * The two server types issue tokens differently: SASVIYA uses the OAuth2
148
+ * resource owner password grant against the built-in, secret-less `sas.cli`
149
+ * client, while SASJS has no password grant and is authenticated through the
150
+ * server's own local login (@see getTokensWithSasjsLogin). Everything else -
151
+ * how the credentials are obtained, and the rule that nothing is prompted for
152
+ * when there is no TTY - is shared by both.
153
+ *
148
154
  * Credentials are resolved in this order (highest precedence first):
149
155
  *
150
156
  * Username: SAS_USERNAME env var > interactive prompt (TTY only)
@@ -175,15 +181,16 @@ var authLogin = function (target_1) {
175
181
  args_1[_i - 1] = arguments[_i];
176
182
  }
177
183
  return __awaiter(void 0, __spreadArray([target_1], __read(args_1), false), void 0, function (target, insecure, passwordStdin) {
178
- var isTty, envUser, envPass, user, hint, pass, promptedPass, _a, access_token, refresh_token, _b, id, name;
179
- var _c, _d, _e;
184
+ var isTty, envUser, envPass, user, hint, pass, promptedPass, _a, access_token_1, refresh_token_1, client, displayName, _b, access_token, refresh_token, _c, id, name;
185
+ var _d, _e, _f, _g, _h;
180
186
  if (insecure === void 0) { insecure = false; }
181
187
  if (passwordStdin === void 0) { passwordStdin = false; }
182
- return __generator(this, function (_f) {
183
- switch (_f.label) {
188
+ return __generator(this, function (_j) {
189
+ switch (_j.label) {
184
190
  case 0:
185
- if (target.serverType !== utils_1.ServerType.SasViya) {
186
- throw new Error("'sasjs auth login' is only supported for SASVIYA targets. " +
191
+ if (target.serverType !== utils_1.ServerType.SasViya &&
192
+ target.serverType !== utils_1.ServerType.Sasjs) {
193
+ throw new Error("'sasjs auth login' is supported for SASVIYA and SASJS targets. " +
187
194
  "Target '".concat(target.name, "' is of type '").concat(target.serverType, "'. ") +
188
195
  "Use 'sasjs add cred -t ".concat(target.name, "' instead."));
189
196
  }
@@ -192,7 +199,7 @@ var authLogin = function (target_1) {
192
199
  }
193
200
  if (insecure) {
194
201
  target = new utils_1.Target(__assign(__assign({}, target.toJson(false)), { httpsAgentOptions: __assign(__assign({}, target.httpsAgentOptions), { allowInsecureRequests: true, rejectUnauthorized: false }) }));
195
- (_c = process.logger) === null || _c === void 0 ? void 0 : _c.warn('Executing with insecure connection.');
202
+ (_d = process.logger) === null || _d === void 0 ? void 0 : _d.warn('Executing with insecure connection.');
196
203
  }
197
204
  isTty = !!process.stdin.isTTY;
198
205
  envUser = process.env.SAS_USERNAME;
@@ -204,7 +211,7 @@ var authLogin = function (target_1) {
204
211
  if (!(isTty && !passwordStdin)) return [3 /*break*/, 3];
205
212
  return [4 /*yield*/, (0, utils_1.getString)('Please enter your SAS username', function (v) { return !!v || 'Username is required.'; })];
206
213
  case 2:
207
- user = _f.sent();
214
+ user = _j.sent();
208
215
  return [3 /*break*/, 4];
209
216
  case 3:
210
217
  hint = passwordStdin
@@ -215,7 +222,7 @@ var authLogin = function (target_1) {
215
222
  if (!passwordStdin) return [3 /*break*/, 6];
216
223
  return [4 /*yield*/, readPasswordFromStdin()];
217
224
  case 5:
218
- pass = _f.sent();
225
+ pass = _j.sent();
219
226
  if (!pass) {
220
227
  throw new Error("--password-stdin was specified but no password was read from stdin. " +
221
228
  "Pipe the password, e.g. echo \"$SAS_PASSWORD\" | sasjs auth login --password-stdin -t ".concat(target.name, "."));
@@ -237,23 +244,35 @@ var authLogin = function (target_1) {
237
244
  }
238
245
  })];
239
246
  case 8:
240
- promptedPass = (_f.sent()).pass;
247
+ promptedPass = (_j.sent()).pass;
241
248
  pass = promptedPass;
242
249
  return [3 /*break*/, 10];
243
250
  case 9: throw new Error("A SAS password is required but was not provided. " +
244
251
  "Set the SAS_PASSWORD environment variable, or pipe the password and use --password-stdin " +
245
252
  "(e.g. echo \"$SAS_PASSWORD\" | sasjs auth login --password-stdin -t ".concat(target.name, ")."));
246
- case 10: return [4 /*yield*/, (0, utils_2.getTokensWithPasswordGrant)(target, user, pass)];
253
+ case 10:
254
+ if (!(target.serverType === utils_1.ServerType.Sasjs)) return [3 /*break*/, 13];
255
+ return [4 /*yield*/, (0, utils_2.getTokensWithSasjsLogin)(target, user, pass, process.env.SASJS_CLIENT)];
247
256
  case 11:
248
- _a = _f.sent(), access_token = _a.access_token, refresh_token = _a.refresh_token;
249
- return [4 /*yield*/, (0, utils_2.fetchLoggedInUser)(target, access_token)];
257
+ _a = _j.sent(), access_token_1 = _a.access_token, refresh_token_1 = _a.refresh_token, client = _a.client, displayName = _a.displayName;
258
+ return [4 /*yield*/, (0, utils_2.saveTokens)(target.name, access_token_1, refresh_token_1, client)];
250
259
  case 12:
251
- _b = _f.sent(), id = _b.id, name = _b.name;
260
+ _j.sent();
261
+ (_e = process.logger) === null || _e === void 0 ? void 0 : _e.success("Logged in as ".concat(displayName || user, " on ").concat(target.serverUrl, "."));
262
+ (_f = process.logger) === null || _f === void 0 ? void 0 : _f.info("The access token is valid for the duration configured on the server " +
263
+ "(24 hours by default). When it expires, re-run 'sasjs auth login -t ".concat(target.name, "'."));
264
+ return [2 /*return*/];
265
+ case 13: return [4 /*yield*/, (0, utils_2.getTokensWithPasswordGrant)(target, user, pass)];
266
+ case 14:
267
+ _b = _j.sent(), access_token = _b.access_token, refresh_token = _b.refresh_token;
268
+ return [4 /*yield*/, (0, utils_2.fetchLoggedInUser)(target, access_token)];
269
+ case 15:
270
+ _c = _j.sent(), id = _c.id, name = _c.name;
252
271
  return [4 /*yield*/, (0, utils_2.saveTokens)(target.name, access_token, refresh_token || '')];
253
- case 13:
254
- _f.sent();
255
- (_d = process.logger) === null || _d === void 0 ? void 0 : _d.success("Logged in as ".concat(id || 'unknown user').concat(name ? " (".concat(name, ")") : '', " on ").concat(target.serverUrl, "."));
256
- (_e = process.logger) === null || _e === void 0 ? void 0 : _e.info("The access token is valid for the duration configured on the server (12 hours by default). " +
272
+ case 16:
273
+ _j.sent();
274
+ (_g = process.logger) === null || _g === void 0 ? void 0 : _g.success("Logged in as ".concat(id || 'unknown user').concat(name ? " (".concat(name, ")") : '', " on ").concat(target.serverUrl, "."));
275
+ (_h = process.logger) === null || _h === void 0 ? void 0 : _h.info("The access token is valid for the duration configured on the server (12 hours by default). " +
257
276
  "When it expires, re-run 'sasjs auth login -t ".concat(target.name, "'."));
258
277
  return [2 /*return*/];
259
278
  }
@@ -103,7 +103,7 @@ var utils_2 = require("@sasjs/utils");
103
103
  // Mock the three orchestration dependencies that authLogin delegates to.
104
104
  // We spy on the utils barrel (../../utils) which re-exports from auth.ts and
105
105
  // config.ts, so the spies are the same references login.ts resolves at runtime.
106
- jest.mock('../../../utils', function () { return (__assign(__assign({}, jest.requireActual('../../../utils')), { getTokensWithPasswordGrant: jest.fn(), fetchLoggedInUser: jest.fn(), saveTokens: jest.fn() })); });
106
+ jest.mock('../../../utils', function () { return (__assign(__assign({}, jest.requireActual('../../../utils')), { getTokensWithPasswordGrant: jest.fn(), getTokensWithSasjsLogin: jest.fn(), fetchLoggedInUser: jest.fn(), saveTokens: jest.fn() })); });
107
107
  var viyaTarget = new utils_1.Target({
108
108
  name: 'viya-test',
109
109
  serverType: utils_1.ServerType.SasViya,
@@ -111,6 +111,12 @@ var viyaTarget = new utils_1.Target({
111
111
  appLoc: '/Public/test',
112
112
  contextName: 'test context'
113
113
  });
114
+ var sasjsTarget = new utils_1.Target({
115
+ name: 'sasjs-test',
116
+ serverType: utils_1.ServerType.Sasjs,
117
+ serverUrl: 'https://sasjs.example.com',
118
+ appLoc: '/Public/app'
119
+ });
114
120
  var sas9Target = new utils_1.Target({
115
121
  name: 'sas9-test',
116
122
  serverType: utils_1.ServerType.Sas9,
@@ -157,10 +163,10 @@ describe('authLogin', function () {
157
163
  // ---------------------------------------------------------------------------
158
164
  // Guard 1: Non-Viya rejection
159
165
  // ---------------------------------------------------------------------------
160
- it('should reject when target.serverType is not SASVIYA', function () { return __awaiter(void 0, void 0, void 0, function () {
166
+ it('should reject when target.serverType is SAS9', function () { return __awaiter(void 0, void 0, void 0, function () {
161
167
  return __generator(this, function (_a) {
162
168
  switch (_a.label) {
163
- case 0: return [4 /*yield*/, expect(authLoginModule.authLogin(sas9Target)).rejects.toThrow("'sasjs auth login' is only supported for SASVIYA targets.")];
169
+ case 0: return [4 /*yield*/, expect(authLoginModule.authLogin(sas9Target)).rejects.toThrow("'sasjs auth login' is supported for SASVIYA and SASJS targets.")];
164
170
  case 1:
165
171
  _a.sent();
166
172
  expect(utilsModule.getTokensWithPasswordGrant).not.toHaveBeenCalled();
@@ -672,4 +678,98 @@ describe('authLogin', function () {
672
678
  }
673
679
  });
674
680
  }); });
681
+ // ---------------------------------------------------------------------------
682
+ // SASJS: local login against a SASjs Server target
683
+ // ---------------------------------------------------------------------------
684
+ describe('SASJS target', function () {
685
+ beforeEach(function () {
686
+ delete process.env.SASJS_CLIENT;
687
+ utilsModule.getTokensWithSasjsLogin.mockResolvedValue({
688
+ access_token: 'sasjs-access',
689
+ refresh_token: 'sasjs-refresh',
690
+ client: 'client-id-1',
691
+ displayName: 'SASJS Test User'
692
+ });
693
+ });
694
+ afterEach(function () {
695
+ delete process.env.SASJS_CLIENT;
696
+ });
697
+ it('should mint tokens with the SASJS login flow instead of the password grant', function () { return __awaiter(void 0, void 0, void 0, function () {
698
+ return __generator(this, function (_a) {
699
+ switch (_a.label) {
700
+ case 0: return [4 /*yield*/, authLoginModule.authLogin(sasjsTarget)];
701
+ case 1:
702
+ _a.sent();
703
+ expect(utilsModule.getTokensWithPasswordGrant).not.toHaveBeenCalled();
704
+ expect(utilsModule.getTokensWithSasjsLogin).toHaveBeenCalledWith(expect.objectContaining({ name: 'sasjs-test' }), 'test-user', 'test-pass', undefined);
705
+ return [2 /*return*/];
706
+ }
707
+ });
708
+ }); });
709
+ it('should persist the client alongside the tokens', function () { return __awaiter(void 0, void 0, void 0, function () {
710
+ return __generator(this, function (_a) {
711
+ switch (_a.label) {
712
+ case 0: return [4 /*yield*/, authLoginModule.authLogin(sasjsTarget)];
713
+ case 1:
714
+ _a.sent();
715
+ expect(utilsModule.saveTokens).toHaveBeenCalledWith('sasjs-test', 'sasjs-access', 'sasjs-refresh', 'client-id-1');
716
+ expect(utilsModule.fetchLoggedInUser).not.toHaveBeenCalled();
717
+ return [2 /*return*/];
718
+ }
719
+ });
720
+ }); });
721
+ it('should pass SASJS_CLIENT through so CI can pin the client', function () { return __awaiter(void 0, void 0, void 0, function () {
722
+ return __generator(this, function (_a) {
723
+ switch (_a.label) {
724
+ case 0:
725
+ process.env.SASJS_CLIENT = 'pinned-client';
726
+ return [4 /*yield*/, authLoginModule.authLogin(sasjsTarget)];
727
+ case 1:
728
+ _a.sent();
729
+ expect(utilsModule.getTokensWithSasjsLogin).toHaveBeenCalledWith(expect.objectContaining({ name: 'sasjs-test' }), 'test-user', 'test-pass', 'pinned-client');
730
+ return [2 /*return*/];
731
+ }
732
+ });
733
+ }); });
734
+ it('should authenticate from env vars with no TTY, as CI would', function () { return __awaiter(void 0, void 0, void 0, function () {
735
+ return __generator(this, function (_a) {
736
+ switch (_a.label) {
737
+ case 0:
738
+ process.env.SAS_USERNAME = 'ci-user';
739
+ process.env.SAS_PASSWORD = 'ci-pass';
740
+ Object.defineProperty(process.stdin, 'isTTY', {
741
+ value: false,
742
+ configurable: true
743
+ });
744
+ return [4 /*yield*/, authLoginModule.authLogin(sasjsTarget)];
745
+ case 1:
746
+ _a.sent();
747
+ expect(utilsModule.getTokensWithSasjsLogin).toHaveBeenCalledWith(expect.objectContaining({ name: 'sasjs-test' }), 'ci-user', 'ci-pass', undefined);
748
+ expect(utils_2.getString).not.toHaveBeenCalled();
749
+ expect(prompts_1.default).not.toHaveBeenCalled();
750
+ return [2 /*return*/];
751
+ }
752
+ });
753
+ }); });
754
+ it('should fall back to the username when the server reports no display name', function () { return __awaiter(void 0, void 0, void 0, function () {
755
+ var success;
756
+ return __generator(this, function (_a) {
757
+ switch (_a.label) {
758
+ case 0:
759
+ ;
760
+ utilsModule.getTokensWithSasjsLogin.mockResolvedValue({
761
+ access_token: 'sasjs-access',
762
+ refresh_token: 'sasjs-refresh',
763
+ client: 'client-id-1'
764
+ });
765
+ success = jest.spyOn(process.logger, 'success');
766
+ return [4 /*yield*/, authLoginModule.authLogin(sasjsTarget)];
767
+ case 1:
768
+ _a.sent();
769
+ expect(success).toHaveBeenCalledWith(expect.stringContaining('Logged in as test-user on'));
770
+ return [2 /*return*/];
771
+ }
772
+ });
773
+ }); });
774
+ });
675
775
  });
@@ -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
  }