@sasjs/cli 6.19.2 → 6.19.3

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.
@@ -89,6 +89,63 @@
89
89
 
90
90
  const clearAll = () => withStore('readwrite', (store) => store.clear())
91
91
 
92
+ /**
93
+ * The build this site is, as the page can see it: mock-config.json carries the
94
+ * stamp the build wrote over its host code and the seed it lays out. Read here
95
+ * rather than passed down, because the page decides whether to keep a stored
96
+ * drive before the worker is asked anything.
97
+ *
98
+ * The base is the worker's script URL, not the page's: the site can be served
99
+ * from a folder, and a page reached at `/demo` (no trailing slash) has a
100
+ * baseURI of `https://host/demo`, which resolves `host/mock-config.json` one
101
+ * level too high. The worker sits at `<base>/sw.js` and fetches the same file
102
+ * relative to itself, so its URL is the one definition of the site folder.
103
+ *
104
+ * Empty when the config cannot be read, which leaves the drive alone: a page
105
+ * that cannot tell which build it is has no business dropping one.
106
+ */
107
+ const readBuildStamp = async (controller) => {
108
+ try {
109
+ const script = controller && controller.scriptURL
110
+ // no controller URL means no way to name the site folder; keep the drive
111
+ if (!script) return ''
112
+ const res = await fetch(new URL('host/mock-config.json', script), {
113
+ cache: 'no-store'
114
+ })
115
+ if (!res.ok) return ''
116
+ const config = await res.json()
117
+ return config && typeof config.buildStamp === 'string'
118
+ ? config.buildStamp
119
+ : ''
120
+ } catch (e) {
121
+ return ''
122
+ }
123
+ }
124
+
125
+ /** Where the stamp of the build that wrote the stored drive is kept. */
126
+ const BUILD_STAMP_KEY = 'sasjs-mock-build'
127
+
128
+ /**
129
+ * The stamp recorded for the stored drive, '' when there is none (a drive
130
+ * from a build before stamps existed) and null when storage cannot be read at
131
+ * all - which is not the same answer, and must not drop a drive on every load.
132
+ */
133
+ const storedBuildStamp = () => {
134
+ try {
135
+ return localStorage.getItem(BUILD_STAMP_KEY) || ''
136
+ } catch (e) {
137
+ return null
138
+ }
139
+ }
140
+
141
+ const keepBuildStamp = (stamp) => {
142
+ try {
143
+ if (stamp) localStorage.setItem(BUILD_STAMP_KEY, stamp)
144
+ } catch (e) {
145
+ /* a browser that refuses storage still runs the site */
146
+ }
147
+ }
148
+
92
149
  const waitForController = () =>
93
150
  new Promise((resolve) => {
94
151
  if (navigator.serviceWorker.controller)
@@ -210,6 +267,32 @@
210
267
  console.error('[sasjs-mock] could not read storage: ' + e)
211
268
  }
212
269
 
270
+ // A stored drive outlives the build that wrote it, so a returning visitor
271
+ // would keep running that build's services - including ones the app being
272
+ // loaded no longer matches. This build stamps the seed it ships, and a drive
273
+ // whose stamp is absent or different is dropped rather than merged: a
274
+ // partial drive from two builds is the one thing that cannot be reasoned
275
+ // about. A site whose config carries no stamp keeps its drives, which is the
276
+ // behaviour every build before this one had.
277
+ const stamp = await readBuildStamp(controller)
278
+ const previous = storedBuildStamp()
279
+ if (
280
+ Object.keys(files).length &&
281
+ stamp &&
282
+ previous !== null &&
283
+ previous !== stamp
284
+ ) {
285
+ console.info(
286
+ '[sasjs-mock] the stored drive is from another build - seeding this one'
287
+ )
288
+ try {
289
+ await clearAll()
290
+ } catch (e) {
291
+ console.error('[sasjs-mock] could not clear storage: ' + e)
292
+ }
293
+ files = {}
294
+ }
295
+
213
296
  if (Object.keys(files).length) {
214
297
  window.__sasjsMockBoot = await askWorker(
215
298
  { type: 'sasjs-load', files },
@@ -221,6 +304,8 @@
221
304
  'sasjs-seed-ack'
222
305
  )
223
306
  }
307
+ if (window.__sasjsMockBoot && !window.__sasjsMockBoot.error)
308
+ keepBuildStamp(stamp)
224
309
  return window.__sasjsMockBoot
225
310
  })()
226
311
  })()
@@ -1100,7 +1100,16 @@ return _webout;
1100
1100
  BUFFERS
1101
1101
  )
1102
1102
  } catch (e) {
1103
- thrown = e && e.stack ? e.stack : String(e)
1103
+ // The stack alone is not an error message: WebKit's carries frames only, so
1104
+ // a failure there arrives at the app as "anonymous@... > Function:219:1" with
1105
+ // the reason nowhere in it. V8's stack already opens with the message, so it
1106
+ // is prepended only when it is missing rather than repeated.
1107
+ const message = e && e.message ? String(e.message) : ''
1108
+ const stack = e && e.stack ? String(e.stack) : ''
1109
+ thrown =
1110
+ stack && message && stack.indexOf(message) < 0
1111
+ ? message + '\n' + stack
1112
+ : stack || String(e)
1104
1113
  logLines.push(String(thrown))
1105
1114
  }
1106
1115
 
@@ -1415,6 +1424,13 @@ const boot = async () => {
1415
1424
  await loadMockConfig()
1416
1425
  await loadWeboutPrelude()
1417
1426
  const { source, files: seed } = await fetchDrive()
1427
+ // a seed replaces the drive, it does not merge into one: the worker survives
1428
+ // page loads, so it can still hold the drive it was given last time, and any
1429
+ // file only that drive had would otherwise outlive the build that wrote it
1430
+ FILES.clear()
1431
+ SIZES.clear()
1432
+ DIRS.clear()
1433
+ DIRS.add('/')
1418
1434
  mkdirp(DRIVE_APP)
1419
1435
  mkdirp(`${DRIVE_ROOT}/files`)
1420
1436
  for (const [path, contents] of Object.entries(seed)) {
@@ -683,7 +683,7 @@ var patchIndexHtml = function (html, version, basePath) {
683
683
  */
684
684
  function buildMockSite(target) {
685
685
  return __awaiter(this, void 0, void 0, function () {
686
- var projectDir, appLocRaw, appLoc, buildFolder, basePath, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, sasPrograms, driveEntries, webFileCount, hostFolder, _b, _c, asset, hostVersion, swPath, snapshotSource, hasSnapshot, indexPath, _d, patched, warnings;
686
+ var projectDir, appLocRaw, appLoc, buildFolder, basePath, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, sasPrograms, driveEntries, webFileCount, hostFolder, _b, _c, asset, hostVersion, buildStamp, swPath, snapshotSource, hasSnapshot, indexPath, _d, patched, warnings;
687
687
  var e_8, _e;
688
688
  var _f, _g, _h, _j, _k, _l;
689
689
  return __generator(this, function (_m) {
@@ -837,6 +837,12 @@ function buildMockSite(target) {
837
837
  .reduce(function (all, buf) { return Buffer.concat([all, buf]); }, Buffer.alloc(0)))
838
838
  .digest('hex')
839
839
  .slice(0, 10);
840
+ buildStamp = (0, crypto_1.createHash)('md5')
841
+ .update(JSON.stringify(Object.keys(seed)
842
+ .sort()
843
+ .map(function (key) { return [key, seed[key]]; })))
844
+ .digest('hex')
845
+ .slice(0, 10);
840
846
  swPath = path_1.default.join(outputFolder, 'sw.js');
841
847
  fs_1.default.writeFileSync(swPath, fs_1.default
842
848
  .readFileSync(swPath, 'utf8')
@@ -857,6 +863,7 @@ function buildMockSite(target) {
857
863
  jsPrograms: jsPrograms,
858
864
  programFolders: PROGRAM_FOLDERS,
859
865
  snapshot: hasSnapshot,
866
+ buildStamp: buildStamp,
860
867
  builtAt: new Date().toISOString()
861
868
  }, null, 2));
862
869
  indexPath = path_1.default.join(outputFolder, 'index.html');
@@ -136,6 +136,34 @@ describe('browser-hosted build', function () {
136
136
  }
137
137
  });
138
138
  }); });
139
+ it('stamps the config with the build, so a page can tell one drive from another', function () { return __awaiter(void 0, void 0, void 0, function () {
140
+ var first;
141
+ return __generator(this, function (_a) {
142
+ switch (_a.label) {
143
+ case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
144
+ case 1:
145
+ _a.sent();
146
+ first = readConfig();
147
+ expect(first.buildStamp).toMatch(/^[0-9a-f]{10}$/);
148
+ // an identical build keeps its stamp: a rebuild that changes nothing must
149
+ // not throw a returning visitor's drive away
150
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
151
+ case 2:
152
+ // an identical build keeps its stamp: a rebuild that changes nothing must
153
+ // not throw a returning visitor's drive away
154
+ _a.sent();
155
+ expect(readConfig().buildStamp).toBe(first.buildStamp);
156
+ // a changed service changes the stamp, which is what makes the page re-seed
157
+ // rather than run the previous build's program
158
+ fs_1.default.writeFileSync(path_1.default.join(root, 'sasjsbuild', 'services', 'public', 'getdata.js'), 'const a = 999\n');
159
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
160
+ case 3:
161
+ _a.sent();
162
+ expect(readConfig().buildStamp).not.toBe(first.buildStamp);
163
+ return [2 /*return*/];
164
+ }
165
+ });
166
+ }); });
139
167
  it('stamps the seed with the drive format the runtime checks', function () { return __awaiter(void 0, void 0, void 0, function () {
140
168
  return __generator(this, function (_a) {
141
169
  switch (_a.label) {
@@ -137,6 +137,10 @@ var loadPersist = function (options) {
137
137
  var controller = options.controller === undefined
138
138
  ? { postMessage: function (message) { return posted.push(message); } }
139
139
  : options.controller;
140
+ // a real controller names the site folder, which is how the page finds the
141
+ // config file; every case gets one unless it is testing that it is missing
142
+ if (controller && !controller.scriptURL)
143
+ controller.scriptURL = options.scriptURL || 'https://host.test/sw.js';
140
144
  var serviceWorker = {
141
145
  controller: controller,
142
146
  // Never resolves by default: the boot sequence then stops before it touches
@@ -158,6 +162,36 @@ var loadPersist = function (options) {
158
162
  var window = {};
159
163
  global.window = window;
160
164
  global.navigator = { serviceWorker: serviceWorker };
165
+ // the page reads its build stamp and what the stored drive was written by
166
+ var local = new Map();
167
+ if (options.storedStamp !== undefined)
168
+ local.set('sasjs-mock-build', options.storedStamp);
169
+ global.localStorage = options.storageUnavailable
170
+ ? {
171
+ getItem: function () {
172
+ throw new Error('storage is denied');
173
+ },
174
+ setItem: function () {
175
+ throw new Error('storage is denied');
176
+ }
177
+ }
178
+ : {
179
+ getItem: function (key) { return (local.has(key) ? local.get(key) : null); },
180
+ setItem: function (key, value) { return local.set(key, value); }
181
+ };
182
+ global.document = {
183
+ baseURI: options.baseURI || 'https://host.test/'
184
+ };
185
+ global.fetch = jest.fn(function (url) { return __awaiter(void 0, void 0, void 0, function () {
186
+ return __generator(this, function (_a) {
187
+ return [2 /*return*/, ({
188
+ ok: true,
189
+ json: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
190
+ return [2 /*return*/, ({ buildStamp: options.buildStamp || '' })];
191
+ }); }); }
192
+ })];
193
+ });
194
+ }); });
161
195
  var idb = options.seeded
162
196
  ? (function () {
163
197
  var fake = createIndexedDB();
@@ -171,7 +205,15 @@ var loadPersist = function (options) {
171
205
  global.indexedDB = idb;
172
206
  require('../assets/persist.js');
173
207
  var reply = function (data) { return __spreadArray([], __read(listeners), false).forEach(function (fn) { return fn({ data: data }); }); };
174
- return { window: window, listeners: listeners, removed: removed, posted: posted, reply: reply, rows: idb.rows };
208
+ return {
209
+ window: window,
210
+ listeners: listeners,
211
+ removed: removed,
212
+ posted: posted,
213
+ reply: reply,
214
+ rows: idb.rows,
215
+ stored: local
216
+ };
175
217
  };
176
218
  describe('page-side store', function () {
177
219
  afterEach(function () {
@@ -179,6 +221,9 @@ describe('page-side store', function () {
179
221
  delete global.window;
180
222
  delete global.navigator;
181
223
  delete global.indexedDB;
224
+ delete global.document;
225
+ delete global.localStorage;
226
+ delete global.fetch;
182
227
  });
183
228
  it('asks the worker for a snapshot and drops the listener when it replies', function () { return __awaiter(void 0, void 0, void 0, function () {
184
229
  var _a, window, listeners, removed, reply, before, pending;
@@ -473,4 +518,162 @@ describe('page-side store', function () {
473
518
  }
474
519
  });
475
520
  }); });
521
+ /**
522
+ * Answers whatever the page asks, so a boot test can assert on which question
523
+ * was asked rather than on the reply.
524
+ */
525
+ var answerBoot = function (controller, listeners) {
526
+ var answering = setInterval(function () {
527
+ var asked = controller.postMessage.mock.calls;
528
+ if (!asked.length)
529
+ return;
530
+ var type = asked[0][0].type;
531
+ __spreadArray([], __read(listeners), false).forEach(function (fn) { return fn({ data: { type: type + '-ack' } }); });
532
+ }, 5);
533
+ return function () { return clearInterval(answering); };
534
+ };
535
+ it("drops a drive written by another build and takes this build's seed", function () { return __awaiter(void 0, void 0, void 0, function () {
536
+ var controller, _a, window, listeners, stored, rows, stop;
537
+ return __generator(this, function (_b) {
538
+ switch (_b.label) {
539
+ case 0:
540
+ controller = { postMessage: jest.fn() };
541
+ _a = loadPersist({
542
+ controller: controller,
543
+ ready: Promise.resolve({}),
544
+ seeded: { '/sasjs/drive/stored.txt': 'stored' },
545
+ buildStamp: 'build-b',
546
+ storedStamp: 'build-a'
547
+ }), window = _a.window, listeners = _a.listeners, stored = _a.stored, rows = _a.rows;
548
+ stop = answerBoot(controller, listeners);
549
+ return [4 /*yield*/, window.__sasjsMockReady];
550
+ case 1:
551
+ _b.sent();
552
+ stop();
553
+ expect(controller.postMessage).toHaveBeenCalledWith({ type: 'sasjs-seed' });
554
+ expect(stored.get('sasjs-mock-build')).toBe('build-b');
555
+ // the old drive is gone rather than merged with the new seed
556
+ expect(rows.size).toBe(0);
557
+ return [2 /*return*/];
558
+ }
559
+ });
560
+ }); });
561
+ it('seeds over a drive from a build before the stamp existed', function () { return __awaiter(void 0, void 0, void 0, function () {
562
+ var controller, _a, window, listeners, stop;
563
+ return __generator(this, function (_b) {
564
+ switch (_b.label) {
565
+ case 0:
566
+ controller = { postMessage: jest.fn() };
567
+ _a = loadPersist({
568
+ controller: controller,
569
+ ready: Promise.resolve({}),
570
+ seeded: { '/sasjs/drive/stored.txt': 'stored' },
571
+ buildStamp: 'build-b'
572
+ }), window = _a.window, listeners = _a.listeners;
573
+ stop = answerBoot(controller, listeners);
574
+ return [4 /*yield*/, window.__sasjsMockReady];
575
+ case 1:
576
+ _b.sent();
577
+ stop();
578
+ expect(controller.postMessage).toHaveBeenCalledWith({ type: 'sasjs-seed' });
579
+ return [2 /*return*/];
580
+ }
581
+ });
582
+ }); });
583
+ it('keeps a drive this build wrote', function () { return __awaiter(void 0, void 0, void 0, function () {
584
+ var controller, _a, window, listeners, stop;
585
+ return __generator(this, function (_b) {
586
+ switch (_b.label) {
587
+ case 0:
588
+ controller = { postMessage: jest.fn() };
589
+ _a = loadPersist({
590
+ controller: controller,
591
+ ready: Promise.resolve({}),
592
+ seeded: { '/sasjs/drive/stored.txt': 'stored' },
593
+ buildStamp: 'build-b',
594
+ storedStamp: 'build-b'
595
+ }), window = _a.window, listeners = _a.listeners;
596
+ stop = answerBoot(controller, listeners);
597
+ return [4 /*yield*/, window.__sasjsMockReady];
598
+ case 1:
599
+ _b.sent();
600
+ stop();
601
+ expect(controller.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'sasjs-load' }));
602
+ return [2 /*return*/];
603
+ }
604
+ });
605
+ }); });
606
+ it('keeps the drive when the site publishes no stamp', function () { return __awaiter(void 0, void 0, void 0, function () {
607
+ var controller, _a, window, listeners, stop;
608
+ return __generator(this, function (_b) {
609
+ switch (_b.label) {
610
+ case 0:
611
+ controller = { postMessage: jest.fn() };
612
+ _a = loadPersist({
613
+ controller: controller,
614
+ ready: Promise.resolve({}),
615
+ seeded: { '/sasjs/drive/stored.txt': 'stored' }
616
+ }), window = _a.window, listeners = _a.listeners;
617
+ stop = answerBoot(controller, listeners);
618
+ return [4 /*yield*/, window.__sasjsMockReady];
619
+ case 1:
620
+ _b.sent();
621
+ stop();
622
+ expect(controller.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'sasjs-load' }));
623
+ return [2 /*return*/];
624
+ }
625
+ });
626
+ }); });
627
+ it('keeps the drive when storage is refused, instead of seeding every load', function () { return __awaiter(void 0, void 0, void 0, function () {
628
+ var controller, _a, window, listeners, stop;
629
+ return __generator(this, function (_b) {
630
+ switch (_b.label) {
631
+ case 0:
632
+ controller = { postMessage: jest.fn() };
633
+ _a = loadPersist({
634
+ controller: controller,
635
+ ready: Promise.resolve({}),
636
+ seeded: { '/sasjs/drive/stored.txt': 'stored' },
637
+ buildStamp: 'build-b',
638
+ storageUnavailable: true
639
+ }), window = _a.window, listeners = _a.listeners;
640
+ stop = answerBoot(controller, listeners);
641
+ return [4 /*yield*/, window.__sasjsMockReady];
642
+ case 1:
643
+ _b.sent();
644
+ stop();
645
+ expect(controller.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'sasjs-load' }));
646
+ return [2 /*return*/];
647
+ }
648
+ });
649
+ }); });
650
+ it('reads the config from the folder the worker sits in, not the page URL', function () { return __awaiter(void 0, void 0, void 0, function () {
651
+ var controller, _a, window, listeners, stop, _b, url, init;
652
+ return __generator(this, function (_c) {
653
+ switch (_c.label) {
654
+ case 0:
655
+ controller = { postMessage: jest.fn() };
656
+ _a = loadPersist({
657
+ controller: controller,
658
+ ready: Promise.resolve({}),
659
+ seeded: { '/sasjs/drive/stored.txt': 'stored' },
660
+ buildStamp: 'build-b',
661
+ storedStamp: 'build-a',
662
+ scriptURL: 'https://host.test/demo/sw.js',
663
+ baseURI: 'https://host.test/demo'
664
+ }), window = _a.window, listeners = _a.listeners;
665
+ stop = answerBoot(controller, listeners);
666
+ return [4 /*yield*/, window.__sasjsMockReady];
667
+ case 1:
668
+ _c.sent();
669
+ stop();
670
+ _b = __read(global.fetch.mock.calls[0], 2), url = _b[0], init = _b[1];
671
+ expect(String(url)).toBe('https://host.test/demo/host/mock-config.json');
672
+ expect(init).toEqual({ cache: 'no-store' });
673
+ // and the stamp it read is acted on
674
+ expect(controller.postMessage).toHaveBeenCalledWith({ type: 'sasjs-seed' });
675
+ return [2 /*return*/];
676
+ }
677
+ });
678
+ }); });
476
679
  });
@@ -136,6 +136,37 @@ describe('browser runtime', function () {
136
136
  }
137
137
  });
138
138
  }); });
139
+ it('replaces the drive when a seed arrives, rather than merging the two', function () { return __awaiter(void 0, void 0, void 0, function () {
140
+ var runtime, info;
141
+ var _a;
142
+ return __generator(this, function (_b) {
143
+ switch (_b.label) {
144
+ case 0:
145
+ runtime = loadRuntime();
146
+ return [4 /*yield*/, seedDrive(runtime, (_a = {}, _a[drivePath('first.txt')] = 'one', _a))
147
+ // a worker outlives the page that seeded it, so a later seed - a page that
148
+ // found a drive from another build, or a reset - arrives with the previous
149
+ // drive still in memory. A file only that drive had must not survive it.
150
+ ];
151
+ case 1:
152
+ _b.sent();
153
+ global.fetch = jest.fn(function (url) { return __awaiter(void 0, void 0, void 0, function () {
154
+ var _a;
155
+ return __generator(this, function (_b) {
156
+ return [2 /*return*/, url.includes('mock-config')
157
+ ? jsonResponse({ appLoc: appLoc })
158
+ : jsonResponse({ files: (_a = {}, _a[drivePath('second.txt')] = 'two', _a) })];
159
+ });
160
+ }); });
161
+ return [4 /*yield*/, runtime.seed()];
162
+ case 2:
163
+ info = _b.sent();
164
+ expect(info.files).toBe(1);
165
+ expect(Object.keys(runtime.dumpAll())).toEqual([drivePath('second.txt')]);
166
+ return [2 /*return*/];
167
+ }
168
+ });
169
+ }); });
139
170
  it('reads a drive file with no format stamp as the current format', function () { return __awaiter(void 0, void 0, void 0, function () {
140
171
  var runtime;
141
172
  var _a;
@@ -294,6 +325,25 @@ describe('browser runtime', function () {
294
325
  expect(result.log).toContain('boom');
295
326
  expect(result.webout).toBe('');
296
327
  });
328
+ it('carries the message when the stack does not, as WebKit reports one', function () {
329
+ var runtime = loadRuntime();
330
+ // WebKit's Error.stack is frames only - V8's opens with the message - so the
331
+ // body a service failure returns had the reason nowhere in it there, which is
332
+ // what a stack of "Function:219:1" in an app's error dialog looks like.
333
+ var result = runtime.run({
334
+ code: [
335
+ 'const error = new Error("webout is not defined")',
336
+ 'error.stack = "global code@https://host.test/sw.js:1:1"',
337
+ 'throw error'
338
+ ].join('\n')
339
+ });
340
+ expect(result.error).toBe('webout is not defined\nglobal code@https://host.test/sw.js:1:1');
341
+ });
342
+ it('does not repeat a message the stack already carries', function () {
343
+ var runtime = loadRuntime();
344
+ var result = runtime.run({ code: 'throw new Error("boom")' });
345
+ expect(result.error.match(/boom/g)).toHaveLength(1);
346
+ });
297
347
  /**
298
348
  * The shims a service reaches for are node's, refusals included: a mock that
299
349
  * answered a copy from a missing file with an empty file, or accepted
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.19.2"
128
+ "version": "6.19.3"
129
129
  }