@sasjs/cli 6.30.0 → 6.31.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.
Files changed (23) hide show
  1. package/build/commands/build/internal/mockSite/assets/api-console.css +365 -0
  2. package/build/commands/build/internal/mockSite/assets/api-console.js +762 -0
  3. package/build/commands/build/internal/mockSite/assets/api-index.html +83 -0
  4. package/build/commands/build/internal/mockSite/assets/boot.js +23 -1
  5. package/build/commands/build/internal/mockSite/assets/persist.js +76 -5
  6. package/build/commands/build/internal/mockSite/assets/runtime-core.js +413 -50
  7. package/build/commands/build/internal/mockSite/assets/sw.js +156 -15
  8. package/build/commands/build/internal/mockSite/mockSite.d.ts +25 -0
  9. package/build/commands/build/internal/mockSite/mockSite.js +311 -24
  10. package/build/commands/build/internal/mockSite/spec/apiConsole.spec.d.ts +1 -0
  11. package/build/commands/build/internal/mockSite/spec/apiConsole.spec.js +1371 -0
  12. package/build/commands/build/internal/mockSite/spec/apiConsoleBasePath.spec.d.ts +1 -0
  13. package/build/commands/build/internal/mockSite/spec/apiConsoleBasePath.spec.js +303 -0
  14. package/build/commands/build/internal/mockSite/spec/boot.spec.js +50 -3
  15. package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +217 -3
  16. package/build/commands/build/internal/mockSite/spec/persist.spec.js +179 -1
  17. package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +546 -0
  18. package/build/commands/build/internal/mockSite/spec/sw.spec.js +407 -53
  19. package/build/commands/version/spec/version.spec.js +11 -1
  20. package/build/commands/version/version.d.ts +5 -3
  21. package/build/commands/version/version.js +17 -7
  22. package/build/types/mockWebBasePath.d.ts +9 -0
  23. package/package.json +13 -32
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __generator = (this && this.__generator) || function (thisArg, body) {
12
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
13
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
14
+ function verb(n) { return function (v) { return step([n, v]); }; }
15
+ function step(op) {
16
+ if (f) throw new TypeError("Generator is already executing.");
17
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
18
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
19
+ if (y = 0, t) op = [op[0] & 2, t.value];
20
+ switch (op[0]) {
21
+ case 0: case 1: t = op; break;
22
+ case 4: _.label++; return { value: op[1], done: false };
23
+ case 5: _.label++; y = op[1]; op = [0]; continue;
24
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
25
+ default:
26
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
27
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
28
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
29
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
30
+ if (t[2]) _.ops.pop();
31
+ _.trys.pop(); continue;
32
+ }
33
+ op = body.call(thisArg, _);
34
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
35
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
36
+ }
37
+ };
38
+ var __read = (this && this.__read) || function (o, n) {
39
+ var m = typeof Symbol === "function" && o[Symbol.iterator];
40
+ if (!m) return o;
41
+ var i = m.call(o), r, ar = [], e;
42
+ try {
43
+ while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
44
+ }
45
+ catch (error) { e = { error: error }; }
46
+ finally {
47
+ try {
48
+ if (r && !r.done && (m = i["return"])) m.call(i);
49
+ }
50
+ finally { if (e) throw e.error; }
51
+ }
52
+ return ar;
53
+ };
54
+ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
55
+ if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
56
+ if (ar || !(i in from)) {
57
+ if (!ar) ar = Array.prototype.slice.call(from, 0, i);
58
+ ar[i] = from[i];
59
+ }
60
+ }
61
+ return to.concat(ar || Array.prototype.slice.call(from));
62
+ };
63
+ var __importDefault = (this && this.__importDefault) || function (mod) {
64
+ return (mod && mod.__esModule) ? mod : { "default": mod };
65
+ };
66
+ Object.defineProperty(exports, "__esModule", { value: true });
67
+ /**
68
+ * @jest-environment jsdom
69
+ * @jest-environment-options {"url": "http://localhost/demo/api/"}
70
+ */
71
+ var fs_1 = __importDefault(require("fs"));
72
+ var path_1 = __importDefault(require("path"));
73
+ /**
74
+ * The console under a base path.
75
+ *
76
+ * `mockWebBasePath` is a supported option (a site published at `/demo`), and the
77
+ * console derives everything from its own URL: the API calls come from
78
+ * `location.pathname` with `/api/` cut off, and the worker is registered beside
79
+ * the site. Both are right at the origin root by accident there - this file runs
80
+ * the same page at `/demo/api/`, where the wrong derivation is silent: the page
81
+ * still renders, and every call goes to a path the host does not answer.
82
+ */
83
+ var assets = path_1.default.join(__dirname, '..', 'assets');
84
+ var page = function () { return fs_1.default.readFileSync(path_1.default.join(assets, 'api-index.html'), 'utf8'); };
85
+ var bodyOf = function (html) {
86
+ return html.slice(html.indexOf('<body>') + 6, html.lastIndexOf('</body>'));
87
+ };
88
+ var jsonResponse = function (body) { return ({
89
+ ok: true,
90
+ status: 200,
91
+ statusText: 'OK',
92
+ headers: { get: function () { return 'application/json'; } },
93
+ json: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
94
+ return [2 /*return*/, body];
95
+ }); }); },
96
+ text: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
97
+ return [2 /*return*/, JSON.stringify(body)];
98
+ }); }); }
99
+ }); };
100
+ var operations = [
101
+ {
102
+ id: 'GET /SASjsApi/user',
103
+ method: 'GET',
104
+ path: '/SASjsApi/user',
105
+ tag: 'User',
106
+ summary: 'List all users',
107
+ params: [],
108
+ requestBodies: [],
109
+ responses: [{ status: 200, description: 'Ok' }],
110
+ implemented: true
111
+ }
112
+ ];
113
+ var settle = function (check) { return __awaiter(void 0, void 0, void 0, function () {
114
+ var i;
115
+ return __generator(this, function (_a) {
116
+ switch (_a.label) {
117
+ case 0:
118
+ i = 0;
119
+ _a.label = 1;
120
+ case 1:
121
+ if (!(i < 400)) return [3 /*break*/, 4];
122
+ if (check())
123
+ return [2 /*return*/];
124
+ return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 5); })];
125
+ case 2:
126
+ _a.sent();
127
+ _a.label = 3;
128
+ case 3:
129
+ i++;
130
+ return [3 /*break*/, 1];
131
+ case 4: throw new Error('the page did not reach the state the case expected');
132
+ }
133
+ });
134
+ }); };
135
+ describe('the API console under a base path', function () {
136
+ afterEach(function () {
137
+ delete navigator.serviceWorker;
138
+ });
139
+ it('finds the folder from a URL without its trailing slash', function () { return __awaiter(void 0, void 0, void 0, function () {
140
+ var realCreate, sw, asked;
141
+ return __generator(this, function (_a) {
142
+ switch (_a.label) {
143
+ case 0:
144
+ // `/demo/api` is the shape a person types, and the page used to read that as
145
+ // the origin root: every call went to /SASjsApi/... and the worker to /sw.js
146
+ history.replaceState({}, '', '/demo/api');
147
+ expect(location.pathname).toBe('/demo/api');
148
+ document.body.innerHTML = bodyOf(page());
149
+ document.head.innerHTML =
150
+ '<meta name="sasjs-mock-version" content="2.0.0" />';
151
+ global.fetch = jest.fn(function (url) { return __awaiter(void 0, void 0, void 0, function () {
152
+ return __generator(this, function (_a) {
153
+ return [2 /*return*/, String(url).includes('operations.json')
154
+ ? jsonResponse(operations)
155
+ : jsonResponse({ format: 1, users: [] })];
156
+ });
157
+ }); });
158
+ realCreate = document.createElement.bind(document);
159
+ jest.spyOn(document, 'createElement').mockImplementation(function (tag) {
160
+ var node = realCreate(tag);
161
+ if (String(tag).toLowerCase() === 'script')
162
+ setTimeout(function () { return node.dispatchEvent(new Event('load')); }, 0);
163
+ return node;
164
+ });
165
+ sw = {
166
+ controller: {},
167
+ register: jest.fn(function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
168
+ return [2 /*return*/, ({})];
169
+ }); }); }),
170
+ addEventListener: jest.fn()
171
+ };
172
+ Object.defineProperty(navigator, 'serviceWorker', {
173
+ value: sw,
174
+ configurable: true
175
+ });
176
+ jest.resetModules();
177
+ require('../assets/api-console.js');
178
+ return [4 /*yield*/, settle(function () { return sw.register.mock.calls.length === 1; })];
179
+ case 1:
180
+ _a.sent();
181
+ expect(sw.register.mock.calls[0][0]).toContain('/demo/sw.js?v=2.0.0');
182
+ expect(sw.register.mock.calls[0][1]).toEqual(expect.objectContaining({ scope: '/demo/' }));
183
+ return [4 /*yield*/, settle(function () { return document.querySelectorAll('#routes .route').length > 0; })];
184
+ case 2:
185
+ _a.sent();
186
+ asked = global.fetch.mock.calls.map(function (call) {
187
+ return String(call[0]);
188
+ });
189
+ expect(asked).toContain('/demo/host/identity.json');
190
+ history.replaceState({}, '', '/demo/api/');
191
+ return [2 /*return*/];
192
+ }
193
+ });
194
+ }); });
195
+ it('registers the worker and calls the API under the folder the site lives in', function () { return __awaiter(void 0, void 0, void 0, function () {
196
+ var calls, realCreate, sw, errors, errorSpy, warnSpy, asked;
197
+ return __generator(this, function (_a) {
198
+ switch (_a.label) {
199
+ case 0:
200
+ document.body.innerHTML = bodyOf(page());
201
+ document.head.innerHTML =
202
+ '<meta name="sasjs-mock-version" content="2.0.0" />';
203
+ calls = [];
204
+ global.fetch = jest.fn(function (url_1) {
205
+ var args_1 = [];
206
+ for (var _i = 1; _i < arguments.length; _i++) {
207
+ args_1[_i - 1] = arguments[_i];
208
+ }
209
+ return __awaiter(void 0, __spreadArray([url_1], __read(args_1), false), void 0, function (url, init) {
210
+ if (init === void 0) { init = {}; }
211
+ return __generator(this, function (_a) {
212
+ if (String(url).includes('operations.json'))
213
+ return [2 /*return*/, jsonResponse(operations)];
214
+ if (String(url).includes('identity.json'))
215
+ return [2 /*return*/, jsonResponse({ format: 1, users: [] })];
216
+ calls.push({ url: String(url), init: init });
217
+ return [2 /*return*/, jsonResponse([])];
218
+ });
219
+ });
220
+ });
221
+ realCreate = document.createElement.bind(document);
222
+ jest.spyOn(document, 'createElement').mockImplementation(function (tag) {
223
+ var node = realCreate(tag);
224
+ if (String(tag).toLowerCase() === 'script')
225
+ setTimeout(function () { return node.dispatchEvent(new Event('load')); }, 0);
226
+ return node;
227
+ });
228
+ sw = {
229
+ controller: {},
230
+ register: jest.fn(function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
231
+ return [2 /*return*/, ({})];
232
+ }); }); }),
233
+ addEventListener: jest.fn()
234
+ };
235
+ Object.defineProperty(navigator, 'serviceWorker', {
236
+ value: sw,
237
+ configurable: true
238
+ });
239
+ errors = [];
240
+ errorSpy = jest
241
+ .spyOn(console, 'error')
242
+ .mockImplementation(function () {
243
+ var args = [];
244
+ for (var _i = 0; _i < arguments.length; _i++) {
245
+ args[_i] = arguments[_i];
246
+ }
247
+ return errors.push(args);
248
+ });
249
+ warnSpy = jest
250
+ .spyOn(console, 'warn')
251
+ .mockImplementation(function () {
252
+ var args = [];
253
+ for (var _i = 0; _i < arguments.length; _i++) {
254
+ args[_i] = arguments[_i];
255
+ }
256
+ return errors.push(args);
257
+ });
258
+ jest.resetModules();
259
+ require('../assets/api-console.js');
260
+ return [4 /*yield*/, settle(function () { return sw.register.mock.calls.length === 1; }).catch(function (e) {
261
+ throw new Error(e.message +
262
+ ' | errors: ' +
263
+ JSON.stringify(errors) +
264
+ ' | routes: ' +
265
+ (document.getElementById('routes') || {}).textContent);
266
+ })];
267
+ case 1:
268
+ _a.sent();
269
+ errorSpy.mockRestore();
270
+ warnSpy.mockRestore();
271
+ expect(sw.register.mock.calls[0][0]).toContain('/demo/sw.js?v=2.0.0');
272
+ expect(sw.register.mock.calls[0][1]).toEqual(expect.objectContaining({ scope: '/demo/' }));
273
+ // the page's data comes from the same folder, not from the origin root
274
+ return [4 /*yield*/, settle(function () { return document.querySelectorAll('#routes .route').length > 0; }).catch(function (e) {
275
+ throw new Error(e.message +
276
+ ' | routes: ' +
277
+ JSON.stringify((document.getElementById('routes') || {}).textContent) +
278
+ ' | fetched: ' +
279
+ JSON.stringify(global.fetch.mock.calls.map(function (c) { return String(c[0]); })) +
280
+ ' | errors: ' +
281
+ JSON.stringify(errors));
282
+ })];
283
+ case 2:
284
+ // the page's data comes from the same folder, not from the origin root
285
+ _a.sent();
286
+ asked = global.fetch.mock.calls.map(function (call) {
287
+ return String(call[0]);
288
+ });
289
+ // the page's own table is fetched relative to the page, so it follows the
290
+ // folder without any substitution; the host files are addressed absolutely
291
+ expect(asked).toContain('operations.json');
292
+ expect(asked).toContain('/demo/host/identity.json');
293
+ __spreadArray([], __read(document.querySelectorAll('#routes .route')), false)[0].click();
294
+ document.getElementById('send').click();
295
+ return [4 /*yield*/, settle(function () { return calls.length > 0; })];
296
+ case 3:
297
+ _a.sent();
298
+ expect(calls[0].url).toBe('/demo/SASjsApi/user');
299
+ return [2 /*return*/];
300
+ }
301
+ });
302
+ }); });
303
+ });
@@ -100,9 +100,11 @@ var loadBoot = function (specs, options) {
100
100
  };
101
101
  var listeners = {};
102
102
  var controller = options.controller || 'present';
103
+ var timelineAtRegister = [];
103
104
  var navigator = {
104
105
  serviceWorker: {
105
106
  register: function (url, init) {
107
+ timelineAtRegister = timeline.slice();
106
108
  registered.push(url + ' ' + JSON.stringify(init));
107
109
  return options.registerFails
108
110
  ? Promise.reject(new Error('nope'))
@@ -127,7 +129,11 @@ var loadBoot = function (specs, options) {
127
129
  global.navigator = navigator;
128
130
  global.window = options.bootError
129
131
  ? { __sasjsMockReady: Promise.resolve({ error: options.bootError }) }
130
- : {};
132
+ : options.driveError
133
+ ? {
134
+ __sasjsMockReady: Promise.resolve({ driveError: options.driveError })
135
+ }
136
+ : {};
131
137
  global.sessionStorage = {
132
138
  getItem: function (key) { return (key in stored ? stored[key] : null); },
133
139
  setItem: function (key, value) {
@@ -146,7 +152,7 @@ var loadBoot = function (specs, options) {
146
152
  late = true;
147
153
  (listeners['controllerchange'] || []).forEach(function (fn) { return fn(); });
148
154
  };
149
- return { appended: appended, timeline: timeline, registered: registered, stored: stored, claim: claim };
155
+ return { appended: appended, timeline: timeline, registered: registered, stored: stored, claim: claim, timelineAtRegister: timelineAtRegister };
150
156
  };
151
157
  var settle = function () { return __awaiter(void 0, void 0, void 0, function () {
152
158
  var i;
@@ -336,6 +342,29 @@ describe('the boot loader', function () {
336
342
  }
337
343
  });
338
344
  }); });
345
+ it('starts the drive restore before the worker is registered', function () { return __awaiter(void 0, void 0, void 0, function () {
346
+ var timelineAtRegister;
347
+ return __generator(this, function (_a) {
348
+ switch (_a.label) {
349
+ case 0:
350
+ timelineAtRegister = loadBoot([{ src: 'sasjs.js' }]).timelineAtRegister;
351
+ return [4 /*yield*/, settle()
352
+ // persist.js reads the page's own IndexedDB and hands the drive to the
353
+ // worker as soon as there is one, so nothing about it needs the worker to be
354
+ // ready: sequenced after the registration it is another download plus a
355
+ // store read on top of the install, which is what made a first visit slow
356
+ ];
357
+ case 1:
358
+ _a.sent();
359
+ // persist.js reads the page's own IndexedDB and hands the drive to the
360
+ // worker as soon as there is one, so nothing about it needs the worker to be
361
+ // ready: sequenced after the registration it is another download plus a
362
+ // store read on top of the install, which is what made a first visit slow
363
+ expect(timelineAtRegister).toEqual(['host/persist.js']);
364
+ return [2 /*return*/];
365
+ }
366
+ });
367
+ }); });
339
368
  it('keeps the attributes an app relies on', function () { return __awaiter(void 0, void 0, void 0, function () {
340
369
  var appended, app;
341
370
  return __generator(this, function (_a) {
@@ -430,6 +459,21 @@ describe('the boot loader', function () {
430
459
  }
431
460
  });
432
461
  }); });
462
+ it('reports a drive that was restored from less than the whole capture', function () { return __awaiter(void 0, void 0, void 0, function () {
463
+ return __generator(this, function (_a) {
464
+ switch (_a.label) {
465
+ case 0:
466
+ // the page still runs - a missing piece only fails the file that needed it -
467
+ // so the error is the only thing that says the drive is incomplete
468
+ loadBoot([], { driveError: 'the drive index answered 500' });
469
+ return [4 /*yield*/, settle()];
470
+ case 1:
471
+ _a.sent();
472
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('the drive index answered 500'));
473
+ return [2 /*return*/];
474
+ }
475
+ });
476
+ }); });
433
477
  it('refuses a boot config it cannot read instead of guessing', function () { return __awaiter(void 0, void 0, void 0, function () {
434
478
  var timeline;
435
479
  return __generator(this, function (_a) {
@@ -527,7 +571,10 @@ describe('the boot loader', function () {
527
571
  // into the next test's globals
528
572
  _b.sent();
529
573
  expect(stored['mock-sw-reloaded']).toEqual('1');
530
- expect(timeline).toEqual(['reload']);
574
+ // the drive restore is requested before the registration (it overlaps the
575
+ // install), so it is already in flight when the page decides to reload; the
576
+ // reload discards it, which costs nothing the cache does not already hold
577
+ expect(timeline).toEqual(['host/persist.js', 'reload']);
531
578
  return [2 /*return*/];
532
579
  }
533
580
  });
@@ -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);
@@ -558,6 +564,66 @@ describe('browser-hosted build', function () {
558
564
  }
559
565
  });
560
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
+ }); });
561
627
  it("leaves a stylesheet alone when the file is not this build's", function () { return __awaiter(void 0, void 0, void 0, function () {
562
628
  var html, specs;
563
629
  return __generator(this, function (_a) {
@@ -889,7 +955,7 @@ describe('browser-hosted build', function () {
889
955
  });
890
956
  }); });
891
957
  it('ships a captured drive snapshot when the project has one', function () { return __awaiter(void 0, void 0, void 0, function () {
892
- var snapshot, result, shipped;
958
+ var snapshot, result, index;
893
959
  var _a;
894
960
  return __generator(this, function (_b) {
895
961
  switch (_b.label) {
@@ -905,11 +971,159 @@ describe('browser-hosted build', function () {
905
971
  case 1:
906
972
  result = _b.sent();
907
973
  expect(result.snapshot).toBe(true);
908
- shipped = JSON.parse(fs_1.default.readFileSync(path_1.default.join(out(), 'host', 'vfs-snapshot.json'), 'utf8'));
909
- 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);
910
981
  // the runtime prefers a snapshot over the seed, so it must not be mistaken
911
982
  // for one of the seeded folders
912
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();
1041
+ return [2 /*return*/];
1042
+ }
1043
+ });
1044
+ }); });
1045
+ /**
1046
+ * The API console is generated, not written.
1047
+ *
1048
+ * It is the page that lets a visitor try the API a mock site is a mock of, so
1049
+ * it has to list the operations the package actually answers - and be
1050
+ * generated from the same route table the worker dispatches, or the page and
1051
+ * the host drift.
1052
+ */
1053
+ it('ships an API console built from the route table', function () { return __awaiter(void 0, void 0, void 0, function () {
1054
+ var result, folder, shipped, sw, listed, operations, createUser, html, version, appHtml, loader;
1055
+ return __generator(this, function (_a) {
1056
+ switch (_a.label) {
1057
+ case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
1058
+ case 1:
1059
+ result = _a.sent();
1060
+ expect(result.apiConsole).toBe(true);
1061
+ folder = path_1.default.join(out(), 'api');
1062
+ shipped = fs_1.default.readdirSync(folder).sort();
1063
+ // every file the folder holds, and the worker's passthrough list is that same
1064
+ // list: a second copy in sw.js would let a new file be answered as an API path
1065
+ expect(shipped).toEqual([
1066
+ 'console.css',
1067
+ 'console.js',
1068
+ 'index.html',
1069
+ 'operations.json'
1070
+ ]);
1071
+ sw = fs_1.default.readFileSync(path_1.default.join(out(), 'sw.js'), 'utf8');
1072
+ listed = /const API_CONSOLE_FILES = (\[[^\]]*\])/.exec(sw);
1073
+ expect(listed).not.toBeNull();
1074
+ expect(JSON.parse(listed[1]).sort()).toEqual(shipped);
1075
+ operations = JSON.parse(fs_1.default.readFileSync(path_1.default.join(folder, 'operations.json'), 'utf8'));
1076
+ expect(operations.length).toBeGreaterThan(20);
1077
+ createUser = operations.find(function (operation) { return operation.id === 'POST /SASjsApi/user'; });
1078
+ expect(createUser).toBeDefined();
1079
+ expect(createUser.params.map(function (parameter) { return parameter.name; })).toEqual(expect.arrayContaining(['username', 'password']));
1080
+ expect(createUser.method).toBe('POST');
1081
+ html = fs_1.default.readFileSync(path_1.default.join(folder, 'index.html'), 'utf8');
1082
+ expect(html).toContain('<script src="console.js"></script>');
1083
+ expect(html).not.toMatch(/<script(?![^>]*\ssrc=)[^>]*>[\s\S]*?<\/script>/);
1084
+ version = /<meta name="sasjs-mock-version" content="([^"]+)" \/>/.exec(html);
1085
+ expect(version).not.toBeNull();
1086
+ appHtml = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
1087
+ loader = /host\/boot\.js\?v=([^"']+)/.exec(appHtml);
1088
+ expect(loader).not.toBeNull();
1089
+ expect(version[1]).toBe(loader[1]);
1090
+ return [2 /*return*/];
1091
+ }
1092
+ });
1093
+ }); });
1094
+ it('leaves the console out when the site ships its own api folder, and says so', function () { return __awaiter(void 0, void 0, void 0, function () {
1095
+ var own, result;
1096
+ return __generator(this, function (_a) {
1097
+ switch (_a.label) {
1098
+ case 0:
1099
+ own = path_1.default.join(root, 'client', 'dist', 'api');
1100
+ fs_1.default.mkdirSync(own, { recursive: true });
1101
+ fs_1.default.writeFileSync(path_1.default.join(own, 'index.html'), '<p>ours</p>');
1102
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())
1103
+ // the project's file is not replaced, and the loss is reported rather than
1104
+ // silent: the build has no way to know which of the two the site wants
1105
+ ];
1106
+ case 1:
1107
+ result = _a.sent();
1108
+ // the project's file is not replaced, and the loss is reported rather than
1109
+ // silent: the build has no way to know which of the two the site wants
1110
+ expect(result.apiConsole).toBe(false);
1111
+ expect(fs_1.default.readFileSync(path_1.default.join(out(), 'api', 'index.html'), 'utf8')).toBe('<p>ours</p>');
1112
+ expect(fs_1.default.existsSync(path_1.default.join(out(), 'api', 'console.js'))).toBe(false);
1113
+ expect(process.logger.warn).toHaveBeenCalledWith(expect.stringContaining('api/index.html'));
1114
+ return [2 /*return*/];
1115
+ }
1116
+ });
1117
+ }); });
1118
+ it('leaves the console out when the target asks for the app alone', function () { return __awaiter(void 0, void 0, void 0, function () {
1119
+ var result;
1120
+ return __generator(this, function (_a) {
1121
+ switch (_a.label) {
1122
+ case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target({ mockApi: false }))];
1123
+ case 1:
1124
+ result = _a.sent();
1125
+ expect(result.apiConsole).toBe(false);
1126
+ expect(fs_1.default.existsSync(path_1.default.join(out(), 'api'))).toBe(false);
913
1127
  return [2 /*return*/];
914
1128
  }
915
1129
  });