@sasjs/cli 6.19.1 → 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.
@@ -114,13 +114,41 @@
114
114
  stamp = ''
115
115
  }
116
116
 
117
- // Registered relative to the page, and scoped to the directory it sits in:
118
- // the site is served from the origin root on one host and from a folder on
119
- // another (a demonstration site beside its own docs), and the worker's scope
120
- // has to follow the page rather than assume the root.
117
+ // Registered against the loader's own URL, which sits in <base>/host/, and
118
+ // scoped to the folder above it: the site is served from the origin root on
119
+ // one host and from a folder on another (a demonstration site beside its own
120
+ // docs). The page cannot be the reference - at `<base>` with no trailing
121
+ // slash a page-relative URL names the origin root, and './' resolves to '/',
122
+ // which is the mis-registration this is here to avoid.
123
+ //
124
+ // The tag the build emits is absolute (`<base>/host/boot.js`) when the target
125
+ // names a folder, and relative (`host/boot.js`) when it does not - so a site
126
+ // published in a folder without naming it depends on its own URL ending in a
127
+ // slash - and in both cases the loader's own URL then carries `<base>`. The
128
+ // fallback below is for the case where there is no currentScript to read.
129
+ var loaderSrc = (script && script.src) || loaderUrl()
130
+
131
+ // The loader always sits in <base>/host/, so without a currentScript the page
132
+ // still names the base. A path that does not end in a slash is ambiguous: it
133
+ // is the folder when its last segment has no dot (`/demo`), and a file when it
134
+ // does (`/demo/index.html`) - only the second has a segment to drop.
135
+ // `location.href` cannot do this: one level above `/demo` is the origin root.
136
+ function loaderUrl() {
137
+ var page = location.pathname
138
+ var lastSegment = page.slice(page.lastIndexOf('/') + 1)
139
+ var base
140
+
141
+ if (lastSegment.indexOf('.') === -1) {
142
+ base = page.charAt(page.length - 1) === '/' ? page : page + '/'
143
+ } else {
144
+ base = page.slice(0, page.lastIndexOf('/') + 1)
145
+ }
146
+
147
+ return location.origin + base + 'host/boot.js'
148
+ }
121
149
  navigator.serviceWorker
122
- .register(new URL('sw.js' + stamp, location.href).href, {
123
- scope: './',
150
+ .register(new URL('../sw.js' + stamp, loaderSrc).href, {
151
+ scope: new URL('../', loaderSrc).pathname,
124
152
  updateViaCache: 'none'
125
153
  })
126
154
  .then(function () {
@@ -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)) {
@@ -517,8 +517,9 @@ var escapeAttribute = function (value) {
517
517
  * order, plus the inline snippets some pages use to set up a global before the
518
518
  * bundle runs - so the host does not dictate how the app is built.
519
519
  */
520
- var bootTag = function (scripts, version) {
521
- return "<script src=\"host/boot.js?v=".concat(version, "\" data-scripts='").concat(escapeAttribute(JSON.stringify(scripts)), "'></script>");
520
+ var bootTag = function (scripts, version, basePath) {
521
+ if (basePath === void 0) { basePath = ''; }
522
+ return "<script src=\"".concat(basePath ? "".concat(basePath, "/host/boot.js") : 'host/boot.js', "?v=").concat(version, "\" data-scripts='").concat(escapeAttribute(JSON.stringify(scripts)), "'></script>");
522
523
  };
523
524
  /**
524
525
  * Script types the browser executes. Anything else (`application/ld+json`, a
@@ -531,28 +532,36 @@ var EXECUTABLE_TYPES = [
531
532
  'application/javascript'
532
533
  ];
533
534
  /**
534
- * The folder the site is published under, as a path with no trailing slash.
535
+ * The folder the site is published under, with a leading slash and no trailing
536
+ * one.
535
537
  *
536
538
  * Empty for the origin root, which is the ordinary case. A host that serves the
537
539
  * demonstration site beside its documentation (a GitHub Pages site whose root is
538
540
  * the docs) names the folder here, and every path the build emits follows it.
541
+ *
542
+ * The shapes the released build accepts keep working - a bare `demo`, a leading
543
+ * or trailing slash, surrounding space - and are normalised rather than
544
+ * refused, because a `fix(mock)` release must not fail a build that passes
545
+ * today. The sasjsconfig schema states the canonical form; this only rejects
546
+ * what could never have worked, a segment that is `.`, `..` or empty.
539
547
  */
540
548
  var normaliseBasePath = function (value) {
541
549
  if (!value)
542
550
  return '';
543
- var base = String(value).trim();
544
- if (!base.startsWith('/'))
545
- base = '/' + base;
546
- base = base.replace(/\/+$/, '');
547
- var segments = base.split('/').slice(1);
551
+ var segments = String(value)
552
+ .trim()
553
+ .replace(/^\/+/, '')
554
+ .replace(/\/+$/, '')
555
+ .split('/');
548
556
  if (!segments.length ||
549
557
  segments.some(function (segment) {
550
- return !/^[A-Za-z0-9._~-]+$/.test(segment) ||
558
+ return !segment ||
551
559
  segment === '.' ||
552
- segment === '..';
560
+ segment === '..' ||
561
+ !/^[A-Za-z0-9._~-]+$/.test(segment);
553
562
  }))
554
563
  throw new Error("mockWebBasePath is not a folder path: ".concat(JSON.stringify(value), ". Use a path such as \"/demo\", or leave it unset for the origin root."));
555
- return base;
564
+ return '/' + segments.join('/');
556
565
  };
557
566
  /**
558
567
  * Tells the interface that this page is answered by the browser host.
@@ -648,7 +657,7 @@ var patchIndexHtml = function (html, version, basePath) {
648
657
  return '';
649
658
  });
650
659
  if (scripts.length) {
651
- var tag = bootTag(scripts, version);
660
+ var tag = bootTag(scripts, version, basePath);
652
661
  patched = /<\/body>/i.test(patched)
653
662
  ? patched.replace(/<\/body>/i, "".concat(tag, "\n</body>"))
654
663
  : patched + tag;
@@ -674,7 +683,7 @@ var patchIndexHtml = function (html, version, basePath) {
674
683
  */
675
684
  function buildMockSite(target) {
676
685
  return __awaiter(this, void 0, void 0, function () {
677
- 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;
678
687
  var e_8, _e;
679
688
  var _f, _g, _h, _j, _k, _l;
680
689
  return __generator(this, function (_m) {
@@ -828,6 +837,12 @@ function buildMockSite(target) {
828
837
  .reduce(function (all, buf) { return Buffer.concat([all, buf]); }, Buffer.alloc(0)))
829
838
  .digest('hex')
830
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);
831
846
  swPath = path_1.default.join(outputFolder, 'sw.js');
832
847
  fs_1.default.writeFileSync(swPath, fs_1.default
833
848
  .readFileSync(swPath, 'utf8')
@@ -848,6 +863,7 @@ function buildMockSite(target) {
848
863
  jsPrograms: jsPrograms,
849
864
  programFolders: PROGRAM_FOLDERS,
850
865
  snapshot: hasSnapshot,
866
+ buildStamp: buildStamp,
851
867
  builtAt: new Date().toISOString()
852
868
  }, null, 2));
853
869
  indexPath = path_1.default.join(outputFolder, 'index.html');
@@ -56,15 +56,24 @@ var loadBoot = function (specs, options) {
56
56
  };
57
57
  return element;
58
58
  };
59
+ // The page carries an absolute boot tag, `<base>/host/boot.js`, so the
60
+ // loader's own URL names the folder whether or not the page URL ends in a
61
+ // slash - a relative tag would resolve against the page and, at `/demo`, name
62
+ // the origin root.
63
+ var pageHref = options.href || 'http://localhost:8123/';
64
+ var stamp = options.noStamp ? '' : '?v=abc123';
65
+ // the tag the build emits: absolute when the target names a folder, relative
66
+ // to the page when it does not
67
+ var loaderSrc = options.basePath
68
+ ? "".concat(new URL(pageHref).origin).concat(options.basePath, "/host/boot.js").concat(stamp)
69
+ : new URL('host/boot.js' + stamp, pageHref).href;
59
70
  var document = {
60
71
  currentScript: options.noScript
61
72
  ? null
62
73
  : {
63
74
  // the builder stamps the boot tag with the host version, and the
64
75
  // loader reuses that query for the worker's own URL
65
- src: options.noStamp
66
- ? 'http://localhost:8123/host/boot.js'
67
- : 'http://localhost:8123/host/boot.js?v=abc123',
76
+ src: loaderSrc,
68
77
  getAttribute: function (name) {
69
78
  if (name !== 'data-scripts')
70
79
  return null;
@@ -126,7 +135,9 @@ var loadBoot = function (specs, options) {
126
135
  }
127
136
  };
128
137
  global.location = {
129
- href: options.href || 'http://localhost:8123/',
138
+ href: pageHref,
139
+ pathname: new URL(pageHref).pathname,
140
+ origin: new URL(pageHref).origin,
130
141
  reload: function () { return timeline.push('reload'); }
131
142
  };
132
143
  jest.spyOn(console, 'error').mockImplementation(function () { });
@@ -185,7 +196,7 @@ describe('the boot loader', function () {
185
196
  // root on one host and from /demo beside its docs on another.
186
197
  expect(registered).toEqual([
187
198
  'http://localhost:8123/sw.js?v=abc123 ' +
188
- JSON.stringify({ scope: './', updateViaCache: 'none' })
199
+ JSON.stringify({ scope: '/', updateViaCache: 'none' })
189
200
  ]);
190
201
  return [2 /*return*/];
191
202
  }
@@ -196,13 +207,104 @@ describe('the boot loader', function () {
196
207
  return __generator(this, function (_a) {
197
208
  switch (_a.label) {
198
209
  case 0:
199
- registered = loadBoot([], { href: 'http://localhost:8123/demo/' }).registered;
210
+ registered = loadBoot([], {
211
+ href: 'http://localhost:8123/demo/',
212
+ basePath: '/demo'
213
+ }).registered;
200
214
  return [4 /*yield*/, settle()];
201
215
  case 1:
202
216
  _a.sent();
203
217
  expect(registered).toEqual([
204
218
  'http://localhost:8123/demo/sw.js?v=abc123 ' +
205
- JSON.stringify({ scope: './', updateViaCache: 'none' })
219
+ JSON.stringify({ scope: '/demo/', updateViaCache: 'none' })
220
+ ]);
221
+ return [2 /*return*/];
222
+ }
223
+ });
224
+ }); });
225
+ it('registers under the folder when the page has no trailing slash', function () { return __awaiter(void 0, void 0, void 0, function () {
226
+ var registered;
227
+ return __generator(this, function (_a) {
228
+ switch (_a.label) {
229
+ case 0:
230
+ registered = loadBoot([], {
231
+ href: 'http://localhost:8123/demo',
232
+ basePath: '/demo'
233
+ }).registered;
234
+ return [4 /*yield*/, settle()];
235
+ case 1:
236
+ _a.sent();
237
+ expect(registered).toEqual([
238
+ 'http://localhost:8123/demo/sw.js?v=abc123 ' +
239
+ JSON.stringify({ scope: '/demo/', updateViaCache: 'none' })
240
+ ]);
241
+ return [2 /*return*/];
242
+ }
243
+ });
244
+ }); });
245
+ it('finds the folder from the page when the document has no currentScript', function () { return __awaiter(void 0, void 0, void 0, function () {
246
+ var registered;
247
+ return __generator(this, function (_a) {
248
+ switch (_a.label) {
249
+ case 0:
250
+ registered = loadBoot([], {
251
+ href: 'http://localhost:8123/demo/',
252
+ basePath: '/demo',
253
+ noScript: true
254
+ }).registered;
255
+ return [4 /*yield*/, settle()
256
+ // no tag, no version: the fallback cannot carry the build's stamp, so this
257
+ // path registers the folder's worker without a cache-busting query
258
+ ];
259
+ case 1:
260
+ _a.sent();
261
+ // no tag, no version: the fallback cannot carry the build's stamp, so this
262
+ // path registers the folder's worker without a cache-busting query
263
+ expect(registered).toEqual([
264
+ 'http://localhost:8123/demo/sw.js ' +
265
+ JSON.stringify({ scope: '/demo/', updateViaCache: 'none' })
266
+ ]);
267
+ return [2 /*return*/];
268
+ }
269
+ });
270
+ }); });
271
+ it('treats a path with no trailing slash as the folder, not a file', function () { return __awaiter(void 0, void 0, void 0, function () {
272
+ var registered;
273
+ return __generator(this, function (_a) {
274
+ switch (_a.label) {
275
+ case 0:
276
+ registered = loadBoot([], {
277
+ href: 'http://localhost:8123/demo',
278
+ basePath: '/demo',
279
+ noScript: true
280
+ }).registered;
281
+ return [4 /*yield*/, settle()];
282
+ case 1:
283
+ _a.sent();
284
+ expect(registered).toEqual([
285
+ 'http://localhost:8123/demo/sw.js ' +
286
+ JSON.stringify({ scope: '/demo/', updateViaCache: 'none' })
287
+ ]);
288
+ return [2 /*return*/];
289
+ }
290
+ });
291
+ }); });
292
+ it('drops a file name from the path, and keeps the folder above it', function () { return __awaiter(void 0, void 0, void 0, function () {
293
+ var registered;
294
+ return __generator(this, function (_a) {
295
+ switch (_a.label) {
296
+ case 0:
297
+ registered = loadBoot([], {
298
+ href: 'http://localhost:8123/demo/index.html',
299
+ basePath: '/demo',
300
+ noScript: true
301
+ }).registered;
302
+ return [4 /*yield*/, settle()];
303
+ case 1:
304
+ _a.sent();
305
+ expect(registered).toEqual([
306
+ 'http://localhost:8123/demo/sw.js ' +
307
+ JSON.stringify({ scope: '/demo/', updateViaCache: 'none' })
206
308
  ]);
207
309
  return [2 /*return*/];
208
310
  }
@@ -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) {
@@ -375,9 +403,9 @@ describe('browser-hosted build', function () {
375
403
  _a.sent();
376
404
  html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
377
405
  expect(html).toContain('serverurl="/demo"');
378
- // the loader and the host's files stay relative to the page, so the same
379
- // build works at the origin root and under the folder
380
- expect(html).toContain('<script src="host/boot.js?v=');
406
+ // the boot tag is absolute and names the folder, so a page at `/demo` with
407
+ // no trailing slash still loads the loader from `/demo/host/`
408
+ expect(html).toContain('<script src="/demo/host/boot.js?v=');
381
409
  return [2 /*return*/];
382
410
  }
383
411
  });
@@ -395,6 +423,50 @@ describe('browser-hosted build', function () {
395
423
  }
396
424
  });
397
425
  }); });
426
+ it('normalises the folder shapes the released build already accepts', function () { return __awaiter(void 0, void 0, void 0, function () {
427
+ var _a, _b, value, html_1, e_3_1, html;
428
+ var e_3, _c;
429
+ return __generator(this, function (_d) {
430
+ switch (_d.label) {
431
+ case 0:
432
+ _d.trys.push([0, 5, 6, 7]);
433
+ _a = __values(['/demo/', ' /demo ', 'demo']), _b = _a.next();
434
+ _d.label = 1;
435
+ case 1:
436
+ if (!!_b.done) return [3 /*break*/, 4];
437
+ value = _b.value;
438
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target({ mockWebBasePath: value }))];
439
+ case 2:
440
+ _d.sent();
441
+ html_1 = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
442
+ expect(html_1).toContain('serverurl="/demo"');
443
+ _d.label = 3;
444
+ case 3:
445
+ _b = _a.next();
446
+ return [3 /*break*/, 1];
447
+ case 4: return [3 /*break*/, 7];
448
+ case 5:
449
+ e_3_1 = _d.sent();
450
+ e_3 = { error: e_3_1 };
451
+ return [3 /*break*/, 7];
452
+ case 6:
453
+ try {
454
+ if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
455
+ }
456
+ finally { if (e_3) throw e_3.error; }
457
+ return [7 /*endfinally*/];
458
+ case 7:
459
+ // explicit empty is the origin root, as it was before the option existed
460
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target({ mockWebBasePath: '' }))];
461
+ case 8:
462
+ // explicit empty is the origin root, as it was before the option existed
463
+ _d.sent();
464
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
465
+ expect(html).toContain('serverurl=""');
466
+ return [2 /*return*/];
467
+ }
468
+ });
469
+ }); });
398
470
  it('points the app at itself and installs the boot loader', function () { return __awaiter(void 0, void 0, void 0, function () {
399
471
  var html;
400
472
  return __generator(this, function (_a) {
@@ -630,7 +702,7 @@ describe('browser-hosted build', function () {
630
702
  }); });
631
703
  it('stops seeding bytes once the drive budget is spent, and names the file', function () { return __awaiter(void 0, void 0, void 0, function () {
632
704
  var chunk, _a, _b, name_1, seed, seeded;
633
- var e_3, _c;
705
+ var e_4, _c;
634
706
  var _d;
635
707
  return __generator(this, function (_e) {
636
708
  switch (_e.label) {
@@ -643,12 +715,12 @@ describe('browser-hosted build', function () {
643
715
  fs_1.default.writeFileSync(path_1.default.join(root, 'sasjsbuild', 'mock-storage', name_1), chunk);
644
716
  }
645
717
  }
646
- catch (e_3_1) { e_3 = { error: e_3_1 }; }
718
+ catch (e_4_1) { e_4 = { error: e_4_1 }; }
647
719
  finally {
648
720
  try {
649
721
  if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
650
722
  }
651
- finally { if (e_3) throw e_3.error; }
723
+ finally { if (e_4) throw e_4.error; }
652
724
  }
653
725
  return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())
654
726
  // the budget is spent in path order, so the drive is the same on every host:
@@ -734,7 +806,7 @@ describe('browser-hosted build', function () {
734
806
  */
735
807
  it('builds an interface that has no services, and says the drive is empty', function () { return __awaiter(void 0, void 0, void 0, function () {
736
808
  var _a, _b, folder, warn, result;
737
- var e_4, _c;
809
+ var e_5, _c;
738
810
  var _d;
739
811
  return __generator(this, function (_e) {
740
812
  switch (_e.label) {
@@ -749,12 +821,12 @@ describe('browser-hosted build', function () {
749
821
  });
750
822
  }
751
823
  }
752
- catch (e_4_1) { e_4 = { error: e_4_1 }; }
824
+ catch (e_5_1) { e_5 = { error: e_5_1 }; }
753
825
  finally {
754
826
  try {
755
827
  if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
756
828
  }
757
- finally { if (e_4) throw e_4.error; }
829
+ finally { if (e_5) throw e_5.error; }
758
830
  }
759
831
  warn = jest.spyOn((_d = process.logger) !== null && _d !== void 0 ? _d : console, 'warn');
760
832
  return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
@@ -776,7 +848,7 @@ describe('browser-hosted build', function () {
776
848
  */
777
849
  it('warns when the build folder holds only folders that are not programs', function () { return __awaiter(void 0, void 0, void 0, function () {
778
850
  var _a, _b, folder, warn, result;
779
- var e_5, _c;
851
+ var e_6, _c;
780
852
  var _d;
781
853
  return __generator(this, function (_e) {
782
854
  switch (_e.label) {
@@ -790,12 +862,12 @@ describe('browser-hosted build', function () {
790
862
  });
791
863
  }
792
864
  }
793
- catch (e_5_1) { e_5 = { error: e_5_1 }; }
865
+ catch (e_6_1) { e_6 = { error: e_6_1 }; }
794
866
  finally {
795
867
  try {
796
868
  if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
797
869
  }
798
- finally { if (e_5) throw e_5.error; }
870
+ finally { if (e_6) throw e_6.error; }
799
871
  }
800
872
  write(path_1.default.join(root, 'sasjsbuild', 'docs', 'index.html'), '<html></html>');
801
873
  warn = jest.spyOn((_d = process.logger) !== null && _d !== void 0 ? _d : console, 'warn');
@@ -898,8 +970,8 @@ describe('browser-hosted build', function () {
898
970
  });
899
971
  }); });
900
972
  it('refuses a mockWebFolder that would delete the project or its inputs', function () { return __awaiter(void 0, void 0, void 0, function () {
901
- var _a, _b, mockWebFolder, e_6_1;
902
- var e_6, _c;
973
+ var _a, _b, mockWebFolder, e_7_1;
974
+ var e_7, _c;
903
975
  return __generator(this, function (_d) {
904
976
  switch (_d.label) {
905
977
  case 0:
@@ -930,14 +1002,14 @@ describe('browser-hosted build', function () {
930
1002
  return [3 /*break*/, 1];
931
1003
  case 4: return [3 /*break*/, 7];
932
1004
  case 5:
933
- e_6_1 = _d.sent();
934
- e_6 = { error: e_6_1 };
1005
+ e_7_1 = _d.sent();
1006
+ e_7 = { error: e_7_1 };
935
1007
  return [3 /*break*/, 7];
936
1008
  case 6:
937
1009
  try {
938
1010
  if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
939
1011
  }
940
- finally { if (e_6) throw e_6.error; }
1012
+ finally { if (e_7) throw e_7.error; }
941
1013
  return [7 /*endfinally*/];
942
1014
  case 7:
943
1015
  expect(fs_1.default.existsSync(path_1.default.join(root, 'client', 'dist', 'index.html'))).toBe(true);
@@ -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
@@ -5,3 +5,4 @@ export { TargetScope } from './targetScope';
5
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';
8
+ export * from './mockWebBasePath';
@@ -24,3 +24,4 @@ Object.defineProperty(exports, "CoverageType", { enumerable: true, get: function
24
24
  Object.defineProperty(exports, "CoverageState", { enumerable: true, get: function () { return testing_1.CoverageState; } });
25
25
  Object.defineProperty(exports, "TestResultStatus", { enumerable: true, get: function () { return testing_1.TestResultStatus; } });
26
26
  __exportStar(require("./log"), exports);
27
+ __exportStar(require("./mockWebBasePath"), exports);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `mockWebBasePath` on a stream config.
3
+ *
4
+ * A `.ts` rather than a `.d.ts` so it compiles into `build/types/` with the
5
+ * rest of the package: tsc does not emit an ambient input file, and this one
6
+ * would otherwise type the CLI's own build and vanish from the published one.
7
+ *
8
+ * The option belongs to the mock target the CLI reads, but `StreamConfig` in
9
+ * @sasjs/utils 4.9.0 predates it, so without this the property is invisible to
10
+ * anyone typing a sasjsconfig and to anything that round-trips a target through
11
+ * the type. The field lands upstream in sasjs/utils#317; when the pin moves past
12
+ * that release, delete this file and the declaration merges from the package
13
+ * itself.
14
+ */
15
+ export {};
16
+ declare module '@sasjs/utils/types/config' {
17
+ interface StreamConfig {
18
+ /**
19
+ * The folder the mock site is published in, as a path with no trailing
20
+ * slash (`/demo`). Unset means the origin root.
21
+ */
22
+ mockWebBasePath?: string;
23
+ }
24
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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.1"
128
+ "version": "6.19.3"
129
129
  }