@sasjs/cli 6.29.0 → 6.30.1

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.
@@ -140,6 +140,12 @@ describe('browser-hosted build', function () {
140
140
  var readConfig = function () {
141
141
  return JSON.parse(fs_1.default.readFileSync(path_1.default.join(out(), 'host', 'mock-config.json'), 'utf8'));
142
142
  };
143
+ var readIndex = function () {
144
+ return JSON.parse(fs_1.default.readFileSync(path_1.default.join(out(), 'host', 'vfs-snapshot.json'), 'utf8'));
145
+ };
146
+ var readShard = function (id) {
147
+ return JSON.parse(fs_1.default.readFileSync(path_1.default.join(out(), 'host', "vfs-snapshot-".concat(id, ".json")), 'utf8')).files;
148
+ };
143
149
  beforeEach(function () {
144
150
  root = fs_1.default.mkdtempSync(path_1.default.join(require('os').tmpdir(), 'sasjs-mock-'));
145
151
  createProject(root);
@@ -297,6 +303,7 @@ describe('browser-hosted build', function () {
297
303
  vm.createContext(realm);
298
304
  vm.runInContext(bundle, realm);
299
305
  expect(typeof realm.SASjsCode.createCodeService).toBe('function');
306
+ expect(typeof realm.SASjsCode.LintConfig).toBe('function');
300
307
  return [2 /*return*/];
301
308
  }
302
309
  });
@@ -557,6 +564,66 @@ describe('browser-hosted build', function () {
557
564
  }
558
565
  });
559
566
  }); });
567
+ /**
568
+ * The loader replays the app's tags only once the worker controls the page.
569
+ * Right for answerability, but it also puts the largest asset on the site
570
+ * behind the largest prerequisite - so a classic script is hinted in the head,
571
+ * where the parser reaches it before the loader runs.
572
+ */
573
+ it('hints a classic app script so it downloads while the worker installs', function () { return __awaiter(void 0, void 0, void 0, function () {
574
+ var html, hint;
575
+ return __generator(this, function (_a) {
576
+ switch (_a.label) {
577
+ case 0:
578
+ write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
579
+ '<!doctype html><html><head><title>App</title></head><body>',
580
+ "<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\" servertype=\"SASJS\"></sasjs>"),
581
+ '<script src="main.abc123.js"></script>',
582
+ '<script src="deferred.js" type="module"></script>',
583
+ '</body></html>'
584
+ ].join('\n'));
585
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
586
+ case 1:
587
+ _a.sent();
588
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
589
+ hint = html.match(/<link rel="preload" as="script" href="([^"]+)" \/>/);
590
+ expect(hint).not.toBeNull();
591
+ // the hint names the URL the replayed tag carries, version query included: a
592
+ // mismatch is a second download rather than a warm cache
593
+ expect(hint[1]).toMatch(/^main\.abc123\.js\?v=[0-9a-f]{10}$/);
594
+ expect(html).toContain("\"src\":\"".concat(hint[1], "\""));
595
+ // and it has to be in the head, ahead of the loader that replays the tag
596
+ expect(html.indexOf('rel="preload"')).toBeLessThan(html.indexOf('host/boot.js'));
597
+ // a module is fetched with different credentials semantics, so a script hint
598
+ // would not match its tag - it is replayed without one
599
+ expect(html).not.toMatch(/as="script" href="deferred/);
600
+ return [2 /*return*/];
601
+ }
602
+ });
603
+ }); });
604
+ it('leaves a script this build does not own unhinted', function () { return __awaiter(void 0, void 0, void 0, function () {
605
+ var html;
606
+ return __generator(this, function (_a) {
607
+ switch (_a.label) {
608
+ case 0:
609
+ // a page may load a script from somewhere else; a version guess or a
610
+ // cross-origin hint only breaks it
611
+ write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
612
+ '<!doctype html><html><head><title>App</title></head><body>',
613
+ "<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\" servertype=\"SASJS\"></sasjs>"),
614
+ '<script src="https://cdn.example.com/lib.js"></script>',
615
+ '<script src="not-in-this-build.js"></script>',
616
+ '</body></html>'
617
+ ].join('\n'));
618
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
619
+ case 1:
620
+ _a.sent();
621
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
622
+ expect(html).not.toContain('rel="preload"');
623
+ return [2 /*return*/];
624
+ }
625
+ });
626
+ }); });
560
627
  it("leaves a stylesheet alone when the file is not this build's", function () { return __awaiter(void 0, void 0, void 0, function () {
561
628
  var html, specs;
562
629
  return __generator(this, function (_a) {
@@ -888,7 +955,7 @@ describe('browser-hosted build', function () {
888
955
  });
889
956
  }); });
890
957
  it('ships a captured drive snapshot when the project has one', function () { return __awaiter(void 0, void 0, void 0, function () {
891
- var snapshot, result, shipped;
958
+ var snapshot, result, index;
892
959
  var _a;
893
960
  return __generator(this, function (_b) {
894
961
  switch (_b.label) {
@@ -904,11 +971,73 @@ describe('browser-hosted build', function () {
904
971
  case 1:
905
972
  result = _b.sent();
906
973
  expect(result.snapshot).toBe(true);
907
- shipped = JSON.parse(fs_1.default.readFileSync(path_1.default.join(out(), 'host', 'vfs-snapshot.json'), 'utf8'));
908
- expect(Object.keys(shipped.files).length).toBe(2);
974
+ index = readIndex();
975
+ expect(index.sharded).toBe(true);
976
+ expect(Object.keys(index.entries).length).toBe(2);
977
+ // two small files under one folder: one boot shard, no request of their own
978
+ expect(result.snapshotShards).toBe(1);
979
+ expect(index.boot).toBe(0);
980
+ expect(readShard(0)).toEqual(snapshot.files);
909
981
  // the runtime prefers a snapshot over the seed, so it must not be mistaken
910
982
  // for one of the seeded folders
911
983
  expect(readConfig().snapshot).toBe(true);
984
+ expect(readConfig().snapshotShards).toBe(1);
985
+ return [2 /*return*/];
986
+ }
987
+ });
988
+ }); });
989
+ /**
990
+ * A captured drive is not one download.
991
+ *
992
+ * The first page of a demonstration site reads a listing and a handful of
993
+ * logos; the bundles behind the tiles are opened by whoever clicks them. One
994
+ * file for the lot makes every visitor pay for all of it before the page
995
+ * renders, so the build splits it: small files in the boot shard, an app's
996
+ * bundle in its own.
997
+ */
998
+ it('ships a large capture as a boot shard plus a piece per bundle', function () { return __awaiter(void 0, void 0, void 0, function () {
999
+ var bundle, capture, result, index, bundlePath, bundleShard, bootBytes, total;
1000
+ var _a;
1001
+ return __generator(this, function (_b) {
1002
+ switch (_b.label) {
1003
+ case 0:
1004
+ bundle = 'x'.repeat(mockSite_1.SHARD_FILE_BYTES + 1024);
1005
+ capture = {
1006
+ files: (_a = {
1007
+ '/sasjs/drive/files/.sasjs/apps.json': '{"apps":[]}'
1008
+ },
1009
+ _a["/sasjs/drive/files/Public/app/demo/logo.png"] = 'not really a png',
1010
+ _a["/sasjs/drive/files/Public/app/demo/services/web/index.bundle.js"] = bundle,
1011
+ _a)
1012
+ };
1013
+ write(path_1.default.join(root, 'sasjsbuild', 'mock-snapshot.json'), JSON.stringify(capture));
1014
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
1015
+ case 1:
1016
+ result = _b.sent();
1017
+ index = readIndex();
1018
+ expect(result.snapshotShards).toBe(2);
1019
+ // what a first page reads is in the boot shard
1020
+ expect(index.entries['/sasjs/drive/files/.sasjs/apps.json'].shard).toBe(0);
1021
+ expect(index.entries["/sasjs/drive/files/Public/app/demo/logo.png"].shard).toBe(0);
1022
+ bundlePath = "/sasjs/drive/files/Public/app/demo/services/web/index.bundle.js";
1023
+ bundleShard = index.entries[bundlePath].shard;
1024
+ expect(bundleShard).not.toBe(0);
1025
+ expect(readShard(bundleShard)[bundlePath]).toBe(bundle);
1026
+ bootBytes = index.shards[0].bytes;
1027
+ total = Object.keys(index.entries).reduce(function (sum, key) { return sum + index.entries[key].bytes; }, 0);
1028
+ expect(bootBytes).toBeLessThan(total / 10);
1029
+ return [2 /*return*/];
1030
+ }
1031
+ });
1032
+ }); });
1033
+ it('refuses a capture written by a runtime of another format', function () { return __awaiter(void 0, void 0, void 0, function () {
1034
+ return __generator(this, function (_a) {
1035
+ switch (_a.label) {
1036
+ case 0:
1037
+ write(path_1.default.join(root, 'sasjsbuild', 'mock-snapshot.json'), JSON.stringify({ format: mockSite_1.DRIVE_FORMAT + 1, files: {} }));
1038
+ return [4 /*yield*/, expect((0, mockSite_1.buildMockSite)(target())).rejects.toThrow(/format/)];
1039
+ case 1:
1040
+ _a.sent();
912
1041
  return [2 /*return*/];
913
1042
  }
914
1043
  });
@@ -213,6 +213,10 @@ var loadPersist = function (options) {
213
213
  // the identity row lives beside the drive, under its own key
214
214
  if (options.seededIdentity)
215
215
  fake.rows.set('current', options.seededIdentity);
216
+ // and the paths a visitor deleted, which a sharded site's index would
217
+ // otherwise restore
218
+ if (options.seededDeleted)
219
+ fake.rows.set('deleted', options.seededDeleted);
216
220
  return fake;
217
221
  })();
218
222
  global.indexedDB = idb;
@@ -228,6 +232,40 @@ var loadPersist = function (options) {
228
232
  stored: local
229
233
  };
230
234
  };
235
+ /**
236
+ * Waits for the store's own handshake to land.
237
+ *
238
+ * A persist message is answered by a chain of transactions - write the files,
239
+ * read the tombstone list, write it back - and the page does not expose the
240
+ * promise. A fixed sleep is a race on a slow runner (Windows was the one that
241
+ * failed), so the assertions poll for the change they expect instead.
242
+ */
243
+ var settleUntil = function (check_1) {
244
+ var args_1 = [];
245
+ for (var _i = 1; _i < arguments.length; _i++) {
246
+ args_1[_i - 1] = arguments[_i];
247
+ }
248
+ return __awaiter(void 0, __spreadArray([check_1], __read(args_1), false), void 0, function (check, timeout) {
249
+ var started;
250
+ if (timeout === void 0) { timeout = 4000; }
251
+ return __generator(this, function (_a) {
252
+ switch (_a.label) {
253
+ case 0:
254
+ started = Date.now();
255
+ _a.label = 1;
256
+ case 1:
257
+ if (!(Date.now() - started < timeout)) return [3 /*break*/, 3];
258
+ if (check())
259
+ return [2 /*return*/];
260
+ return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 10); })];
261
+ case 2:
262
+ _a.sent();
263
+ return [3 /*break*/, 1];
264
+ case 3: return [2 /*return*/];
265
+ }
266
+ });
267
+ });
268
+ };
231
269
  describe('page-side store', function () {
232
270
  afterEach(function () {
233
271
  jest.useRealTimers();
@@ -440,7 +478,7 @@ describe('page-side store', function () {
440
478
  case 0:
441
479
  _a = loadPersist(), window = _a.window, reply = _a.reply, rows = _a.rows;
442
480
  reply({ type: 'sasjs-persist', files: { '/sasjs/drive/c.txt': 'ccc' } });
443
- return [4 /*yield*/, new Promise(function (r) { return setTimeout(r, 20); })];
481
+ return [4 /*yield*/, settleUntil(function () { return rows.get('/sasjs/drive/c.txt') === 'ccc'; })];
444
482
  case 1:
445
483
  _b.sent();
446
484
  expect(rows.get('/sasjs/drive/c.txt')).toBe('ccc');
@@ -448,6 +486,146 @@ describe('page-side store', function () {
448
486
  }
449
487
  });
450
488
  }); });
489
+ /**
490
+ * A deletion has to outlive the worker, and the page is the only store that can
491
+ * keep it.
492
+ *
493
+ * The drive in a sharded build is not all in the page either: the index lists
494
+ * every file the build ships, so a file the visitor deleted comes back on the
495
+ * next load unless the path is remembered here and handed to the worker at
496
+ * boot.
497
+ */
498
+ it('remembers a deleted path, and forgets it when the path is written again', function () { return __awaiter(void 0, void 0, void 0, function () {
499
+ var _a, window, reply, rows;
500
+ return __generator(this, function (_b) {
501
+ switch (_b.label) {
502
+ case 0:
503
+ _a = loadPersist({
504
+ seeded: { '/sasjs/drive/a.txt': 'a', '/sasjs/drive/b.txt': 'b' }
505
+ }), window = _a.window, reply = _a.reply, rows = _a.rows;
506
+ reply({ type: 'sasjs-persist', files: { '/sasjs/drive/a.txt': null } });
507
+ return [4 /*yield*/, settleUntil(function () { return rows.has('deleted'); })];
508
+ case 1:
509
+ _b.sent();
510
+ expect(rows.get('deleted')).toEqual(['/sasjs/drive/a.txt']);
511
+ // the row itself is gone, and the tombstone is what stops the index bringing
512
+ // it back
513
+ expect(rows.has('/sasjs/drive/a.txt')).toBe(false);
514
+ reply({ type: 'sasjs-persist', files: { '/sasjs/drive/a.txt': 'again' } });
515
+ // the tombstone is written back after the file, so poll for it rather than
516
+ // for the write that precedes it
517
+ return [4 /*yield*/, settleUntil(function () { return !rows.has('deleted'); })];
518
+ case 2:
519
+ // the tombstone is written back after the file, so poll for it rather than
520
+ // for the write that precedes it
521
+ _b.sent();
522
+ expect(rows.has('deleted')).toBe(false);
523
+ expect(rows.get('/sasjs/drive/a.txt')).toBe('again');
524
+ return [2 /*return*/];
525
+ }
526
+ });
527
+ }); });
528
+ it('writes no file when the worker could not read the drive whole', function () { return __awaiter(void 0, void 0, void 0, function () {
529
+ var controller, _a, page, listeners, reply, answering, pending;
530
+ return __generator(this, function (_b) {
531
+ switch (_b.label) {
532
+ case 0:
533
+ controller = { postMessage: jest.fn() };
534
+ _a = loadPersist({
535
+ controller: controller,
536
+ ready: Promise.resolve({})
537
+ }), page = _a.window, listeners = _a.listeners, reply = _a.reply;
538
+ answering = setInterval(function () {
539
+ var asked = controller.postMessage.mock.calls;
540
+ if (!asked.length)
541
+ return;
542
+ __spreadArray([], __read(listeners), false).forEach(function (fn) {
543
+ return fn({ data: { type: asked[0][0].type + '-ack', booted: true } });
544
+ });
545
+ }, 5);
546
+ return [4 /*yield*/, page.__sasjsMockReady];
547
+ case 1:
548
+ _b.sent();
549
+ clearInterval(answering);
550
+ pending = page.SASjsMock.download();
551
+ return [4 /*yield*/, Promise.resolve()
552
+ // the worker answers with what it has and says why it could not finish, and a
553
+ // file built from that would be a capture with every app bundle missing
554
+ ];
555
+ case 2:
556
+ _b.sent();
557
+ // the worker answers with what it has and says why it could not finish, and a
558
+ // file built from that would be a capture with every app bundle missing
559
+ reply({
560
+ type: 'sasjs-snapshot',
561
+ files: { '/sasjs/drive/a.txt': 'a' },
562
+ error: 'ensureAll rejected: ENOENT'
563
+ });
564
+ return [4 /*yield*/, expect(pending).rejects.toThrow(/could not be read whole/)];
565
+ case 3:
566
+ _b.sent();
567
+ return [2 /*return*/];
568
+ }
569
+ });
570
+ }); });
571
+ it('drops the recorded deletions with the rest of the drive', function () { return __awaiter(void 0, void 0, void 0, function () {
572
+ var _a, window, listeners, rows, pending;
573
+ return __generator(this, function (_b) {
574
+ switch (_b.label) {
575
+ case 0:
576
+ _a = loadPersist({
577
+ seeded: { '/sasjs/drive/a.txt': 'a' },
578
+ seededDeleted: ['/sasjs/drive/a.txt']
579
+ }), window = _a.window, listeners = _a.listeners, rows = _a.rows;
580
+ pending = window.SASjsMock.reset();
581
+ return [4 /*yield*/, Promise.resolve()];
582
+ case 1:
583
+ _b.sent();
584
+ __spreadArray([], __read(listeners), false).forEach(function (fn) { return fn({ data: { type: 'sasjs-reset-ack' } }); });
585
+ return [4 /*yield*/, pending];
586
+ case 2:
587
+ _b.sent();
588
+ return [4 /*yield*/, settleUntil(function () { return !rows.has('deleted'); })];
589
+ case 3:
590
+ _b.sent();
591
+ expect(rows.has('deleted')).toBe(false);
592
+ return [2 /*return*/];
593
+ }
594
+ });
595
+ }); });
596
+ it('tells the worker which paths were deleted, so its index cannot restore them', function () { return __awaiter(void 0, void 0, void 0, function () {
597
+ var controller, _a, window, listeners, answering;
598
+ return __generator(this, function (_b) {
599
+ switch (_b.label) {
600
+ case 0:
601
+ controller = { postMessage: jest.fn() };
602
+ _a = loadPersist({
603
+ controller: controller,
604
+ ready: Promise.resolve({}),
605
+ seeded: { '/sasjs/drive/b.txt': 'b' },
606
+ seededDeleted: ['/sasjs/drive/a.txt']
607
+ }), window = _a.window, listeners = _a.listeners;
608
+ answering = setInterval(function () {
609
+ var asked = controller.postMessage.mock.calls;
610
+ if (!asked.length)
611
+ return;
612
+ var type = asked[0][0].type;
613
+ __spreadArray([], __read(listeners), false).forEach(function (fn) {
614
+ return fn({ data: { type: type + '-ack', booted: true } });
615
+ });
616
+ }, 5);
617
+ return [4 /*yield*/, window.__sasjsMockReady];
618
+ case 1:
619
+ _b.sent();
620
+ clearInterval(answering);
621
+ expect(controller.postMessage.mock.calls[0][0]).toEqual(expect.objectContaining({
622
+ type: 'sasjs-load',
623
+ deleted: ['/sasjs/drive/a.txt']
624
+ }));
625
+ return [2 /*return*/];
626
+ }
627
+ });
628
+ }); });
451
629
  it('resets the worker and the stored drive together', function () { return __awaiter(void 0, void 0, void 0, function () {
452
630
  var _a, window, posted, reply, rows, pending;
453
631
  return __generator(this, function (_b) {