@sasjs/cli 6.17.0 → 6.18.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.
@@ -31,6 +31,31 @@
31
31
  if (DEBUG) console.log('[sasjs-mock]', ...args)
32
32
  }
33
33
 
34
+ /**
35
+ * A file's contents, as text or as base64 with a flag.
36
+ *
37
+ * An upload is a file, not a string: a PNG, a workbook or an .sas7bdat read
38
+ * with `text()` arrives as replacement characters, and the drive would store
39
+ * the damage under a success status. The bytes are decoded as UTF-8 strictly -
40
+ * if that fails, they are not text by any reading a browser would accept - and
41
+ * carried as base64, which the worker turns back into the bytes the drive
42
+ * keeps.
43
+ */
44
+ const contentsOf = async (value) => {
45
+ const bytes = new Uint8Array(await value.arrayBuffer())
46
+
47
+ try {
48
+ return { csv: new TextDecoder('utf-8', { fatal: true }).decode(bytes) }
49
+ } catch (error) {
50
+ let binary = ''
51
+ const chunk = 0x8000
52
+ for (let i = 0; i < bytes.length; i += chunk)
53
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk))
54
+
55
+ return { csv: btoa(binary), binary: true }
56
+ }
57
+ }
58
+
34
59
  const readBody = async (body) => {
35
60
  const params = {}
36
61
  const tables = []
@@ -39,12 +64,16 @@
39
64
  if (typeof FormData !== 'undefined' && body instanceof FormData) {
40
65
  for (const [key, value] of body.entries()) {
41
66
  if (typeof value === 'string') params[key] = value
42
- else
67
+ else {
68
+ const contents = await contentsOf(value)
69
+
43
70
  tables.push({
44
71
  name: key,
45
72
  filename: value.name || key + '.csv',
46
- csv: await value.text()
73
+ csv: contents.csv,
74
+ ...(contents.binary ? { binary: true } : {})
47
75
  })
76
+ }
48
77
  }
49
78
  return { params, tables }
50
79
  }
@@ -574,7 +574,15 @@ const readJsonBody = async (request) => {
574
574
  name: table.name || `table${index + 1}`,
575
575
  filename:
576
576
  table.filename || `${table.name || 'table' + (index + 1)}.csv`,
577
- contents: table.csv === undefined ? '' : table.csv
577
+ // A file the bridge could not read as UTF-8 travels as base64 with a
578
+ // flag: writing its text would replace the bytes and store the damage
579
+ // under a success status, which is what a binary upload used to do.
580
+ contents:
581
+ table.csv === undefined
582
+ ? ''
583
+ : table.binary
584
+ ? toBytes(table.csv)
585
+ : table.csv
578
586
  }))
579
587
  }
580
588
  }
@@ -648,9 +648,9 @@ function buildMockSite(target) {
648
648
  return __awaiter(this, void 0, void 0, function () {
649
649
  var projectDir, appLocRaw, appLoc, buildFolder, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, sasPrograms, driveEntries, webFileCount, hostFolder, _b, _c, asset, hostVersion, swPath, snapshotSource, hasSnapshot, indexPath, _d, patched, warnings;
650
650
  var e_8, _e;
651
- var _f, _g, _h, _j;
652
- return __generator(this, function (_k) {
653
- switch (_k.label) {
651
+ var _f, _g, _h, _j, _k;
652
+ return __generator(this, function (_l) {
653
+ switch (_l.label) {
654
654
  case 0:
655
655
  projectDir = process.projectDir || process.cwd();
656
656
  appLocRaw = target.appLoc;
@@ -662,7 +662,7 @@ function buildMockSite(target) {
662
662
  path_1.default.join(projectDir, 'sasjsbuild');
663
663
  return [4 /*yield*/, (0, utils_1.folderExists)(buildFolder)];
664
664
  case 1:
665
- if (!(_k.sent())) {
665
+ if (!(_l.sent())) {
666
666
  throw new Error("Build folder not found: ".concat(buildFolder, ". Run `sasjs compile` first."));
667
667
  }
668
668
  webSourceRaw = (_f = target.streamConfig) === null || _f === void 0 ? void 0 : _f.webSourcePath;
@@ -672,7 +672,7 @@ function buildMockSite(target) {
672
672
  webFolder = path_1.default.resolve(projectDir, webSourceRaw);
673
673
  return [4 /*yield*/, (0, utils_1.folderExists)(webFolder)];
674
674
  case 2:
675
- if (!(_k.sent())) {
675
+ if (!(_l.sent())) {
676
676
  throw new Error("Web source folder not found: ".concat(webFolder, ". Check streamConfig.webSourcePath - build the frontend first."));
677
677
  }
678
678
  if (!fs_1.default.existsSync(path_1.default.join(webFolder, 'index.html'))) {
@@ -693,21 +693,20 @@ function buildMockSite(target) {
693
693
  jsPrograms = countPrograms(seed, '.js');
694
694
  sasPrograms = countPrograms(seed, '.sas');
695
695
  driveEntries = Object.keys(seed).length;
696
- // A build holding SAS and no JavaScript is a SAS target: its services cannot
697
- // run here, and seeding them would produce a site whose buttons answer 400.
698
- // A build holding NEITHER is a frontend host - the app is the whole thing and
699
- // the worker answers its API - which is what an interface with no services
700
- // compiles to, and the case this site is built for.
696
+ // A build whose services are SAS is still a site worth having, and it is built:
697
+ // the programs are seeded on the drive, where a visitor can open them in Studio
698
+ // and read them - which is how a deployment's own backend code is inspected
699
+ // without a SAS licence and without a login. What they cannot do is run here,
700
+ // so the build says so rather than letting a Run button be the surprise.
701
701
  if (jsPrograms === 0 && sasPrograms > 0) {
702
- throw new Error("No .js programs in ".concat(buildFolder, "/services, /jobs or /tests, but ").concat(sasPrograms, " SAS program(s). This target builds SAS code, which cannot run in a browser. ") +
703
- 'Build a target whose services are JavaScript (typically a mocks project).');
702
+ (_h = process.logger) === null || _h === void 0 ? void 0 : _h.warn("".concat(sasPrograms, " SAS program(s) and no JavaScript in ").concat(buildFolder, ": they are placed on the drive to read, and cannot run in a browser."));
704
703
  }
705
704
  // No PROGRAMS is a different thing again: a compile that produced nothing, or
706
705
  // a stale sasjsbuild from an unrelated command - `sasjs doc` leaves a folder
707
706
  // behind that is not a program folder but does reach the drive, so the count
708
707
  // that decides this is of programs, not of files.
709
708
  if (jsPrograms + sasPrograms === 0) {
710
- (_h = process.logger) === null || _h === void 0 ? void 0 : _h.warn("No compiled programs in ".concat(buildFolder, ": this site ships with an empty drive. ") +
709
+ (_j = process.logger) === null || _j === void 0 ? void 0 : _j.warn("No compiled programs in ".concat(buildFolder, ": this site ships with an empty drive. ") +
711
710
  'That is expected for an interface with no services; for anything else, check that compile wrote the target.');
712
711
  }
713
712
  // The package supplies the drive rules the worker answers with, so an install
@@ -720,7 +719,7 @@ function buildMockSite(target) {
720
719
  'or check that @sasjs/api still publishes build/browser.js.');
721
720
  }
722
721
  if (skipped.length) {
723
- (_j = process.logger) === null || _j === void 0 ? void 0 : _j.warn("Left ".concat(skipped.length, " file(s) off the mock drive, over the ").concat(megabytes(exports.MAX_BINARY_SEED_BYTES), " per-file or ").concat(megabytes(exports.MAX_SEED_BYTES), " total the browser build seeds: ") +
722
+ (_k = process.logger) === null || _k === void 0 ? void 0 : _k.warn("Left ".concat(skipped.length, " file(s) off the mock drive, over the ").concat(megabytes(exports.MAX_BINARY_SEED_BYTES), " per-file or ").concat(megabytes(exports.MAX_SEED_BYTES), " total the browser build seeds: ") +
724
723
  "".concat(skipped
725
724
  .slice(0, 3)
726
725
  .map(function (file) {
@@ -767,7 +766,7 @@ function buildMockSite(target) {
767
766
  case 3:
768
767
  // 2. the linter, bundled from the installed packages so the code routes answer
769
768
  // with the rules this CLI resolved rather than a second implementation
770
- _k.sent();
769
+ _l.sent();
771
770
  // sw.js sits at the site root: a worker can only control the folder it is
772
771
  // served from, and the app has to be controlled from /
773
772
  fs_1.default.copyFileSync(path_1.default.join(ASSETS_FOLDER, 'sw.js'), path_1.default.join(outputFolder, 'sw.js'));
@@ -210,6 +210,32 @@ describe('page-side bridge', function () {
210
210
  }
211
211
  });
212
212
  }); });
213
+ it('carries a binary file as base64 rather than as damaged text', function () { return __awaiter(void 0, void 0, void 0, function () {
214
+ var fetchImpl, XHR, bytes, form, xhr, _a, init, payload;
215
+ return __generator(this, function (_b) {
216
+ switch (_b.label) {
217
+ case 0:
218
+ fetchImpl = jest.fn(function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
219
+ return [2 /*return*/, jsonResponse('{"status":"success"}')];
220
+ }); }); });
221
+ XHR = loadBridge(fetchImpl).XHR;
222
+ bytes = new Uint8Array([
223
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
224
+ ]);
225
+ form = new FormData();
226
+ form.append('file', new Blob([bytes], { type: 'image/png' }), 'logo.png');
227
+ xhr = new XHR();
228
+ return [4 /*yield*/, run(xhr, 'POST', '/SASjsApi/drive/file', form)];
229
+ case 1:
230
+ _b.sent();
231
+ _a = __read(fetchImpl.mock.calls[0], 2), init = _a[1];
232
+ payload = JSON.parse(init.body);
233
+ expect(payload.tables[0].binary).toBe(true);
234
+ expect(payload.tables[0].csv).toBe(btoa('‰PNG\r\n\n'));
235
+ return [2 /*return*/];
236
+ }
237
+ });
238
+ }); });
213
239
  it('parses the response when the app asked for json', function () { return __awaiter(void 0, void 0, void 0, function () {
214
240
  var XHR, xhr;
215
241
  return __generator(this, function (_a) {
@@ -667,25 +667,32 @@ describe('browser-hosted build', function () {
667
667
  }
668
668
  });
669
669
  }); });
670
- it('refuses a target whose services are SAS, and keeps the previous site', function () { return __awaiter(void 0, void 0, void 0, function () {
671
- return __generator(this, function (_a) {
672
- switch (_a.label) {
673
- case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
674
- case 1:
675
- _a.sent();
676
- write(path_1.default.join(out(), 'stale.txt'), 'previous');
670
+ /**
671
+ * A SAS backend does not run in a browser, but the programs are still worth
672
+ * shipping: the drive holds them, so a visitor can open them in Studio and
673
+ * read what a deployment's services do. The build warns and carries on.
674
+ */
675
+ it('builds a target whose services are SAS, and warns they cannot run', function () { return __awaiter(void 0, void 0, void 0, function () {
676
+ var warn, result, seed;
677
+ var _a;
678
+ return __generator(this, function (_b) {
679
+ switch (_b.label) {
680
+ case 0:
677
681
  fs_1.default.rmSync(path_1.default.join(root, 'sasjsbuild', 'services'), {
678
682
  recursive: true,
679
683
  force: true
680
684
  });
681
685
  write(path_1.default.join(root, 'sasjsbuild', 'services', 'public', 'getdata.sas'), '%put sas;\n');
682
- return [4 /*yield*/, expect((0, mockSite_1.buildMockSite)(target())).rejects.toThrow(/No \.js programs/)
683
- // the failure is not allowed to take the last good site with it
684
- ];
685
- case 2:
686
- _a.sent();
687
- // the failure is not allowed to take the last good site with it
688
- expect(fs_1.default.readFileSync(path_1.default.join(out(), 'stale.txt'), 'utf8')).toBe('previous');
686
+ warn = jest.spyOn((_a = process.logger) !== null && _a !== void 0 ? _a : console, 'warn');
687
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
688
+ case 1:
689
+ result = _b.sent();
690
+ expect(result.jsPrograms).toBe(0);
691
+ expect(result.driveEntries).toBeGreaterThan(0);
692
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('cannot run in a browser'));
693
+ seed = readSeed();
694
+ expect(Object.keys(seed.files).some(function (key) { return key.endsWith('/getdata.sas'); })).toBe(true);
695
+ warn.mockRestore();
689
696
  return [2 /*return*/];
690
697
  }
691
698
  });
@@ -1139,6 +1139,46 @@ describe('service worker host', function () {
1139
1139
  });
1140
1140
  }); });
1141
1141
  });
1142
+ it('writes a binary upload as its bytes, not as the text it decodes to', function () { return __awaiter(void 0, void 0, void 0, function () {
1143
+ var dispatch, image, created, read, _a, _b;
1144
+ return __generator(this, function (_c) {
1145
+ switch (_c.label) {
1146
+ case 0:
1147
+ dispatch = seeded().dispatch;
1148
+ image = new Uint8Array([
1149
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
1150
+ ]);
1151
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file', {
1152
+ method: 'POST',
1153
+ headers: { 'content-type': 'application/json' },
1154
+ body: JSON.stringify({
1155
+ params: { filePath: '/Public/logo.png' },
1156
+ tables: [
1157
+ {
1158
+ name: 'file',
1159
+ filename: 'logo.png',
1160
+ csv: btoa(String.fromCharCode.apply(null, Array.from(image))),
1161
+ binary: true
1162
+ }
1163
+ ]
1164
+ })
1165
+ })];
1166
+ case 1:
1167
+ created = _c.sent();
1168
+ expect(created.status).toBe(200);
1169
+ return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/Public/logo.png'))];
1170
+ case 2:
1171
+ read = _c.sent();
1172
+ expect(read.status).toBe(200);
1173
+ _a = expect;
1174
+ _b = Uint8Array.bind;
1175
+ return [4 /*yield*/, read.arrayBuffer()];
1176
+ case 3:
1177
+ _a.apply(void 0, [new (_b.apply(Uint8Array, [void 0, _c.sent()]))()]).toEqual(image);
1178
+ return [2 /*return*/];
1179
+ }
1180
+ });
1181
+ }); });
1142
1182
  it('answers 404 for a drive file the drive does not hold', function () { return __awaiter(void 0, void 0, void 0, function () {
1143
1183
  var dispatch, response, _a;
1144
1184
  return __generator(this, function (_b) {
package/package.json CHANGED
@@ -125,5 +125,5 @@
125
125
  "ws": "8.21.0",
126
126
  "nanoid": "3.3.18"
127
127
  },
128
- "version": "6.17.0"
128
+ "version": "6.18.0"
129
129
  }