@sasjs/cli 6.1.0 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,87 @@ const text = (body, status) =>
40
40
  headers: { 'content-type': 'text/plain' }
41
41
  })
42
42
 
43
+ /**
44
+ * A byte body travels as base64. The runtime encodes it, and refuses a drive
45
+ * entry that will not decode before it hands the value over, so this is a plain
46
+ * decode - the corrupt-entry error lives in one place, runtime-core.js.
47
+ */
48
+ const toBytes = (base64) => {
49
+ const binary = atob(String(base64 || '').replace(/\s+/g, ''))
50
+ const out = new Uint8Array(binary.length)
51
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
52
+ return out
53
+ }
54
+
55
+ // ------------------------------------------------------------ webout headers
56
+ // A service declares the response it wants by writing `Name: value` lines to
57
+ // the file the preamble hands it as _SASJS_WEBOUT_HEADERS - the same channel
58
+ // %mp_streamfile uses on the SAS side, and the one the server reads. Only the
59
+ // two headers that change how a reply is delivered are applied: the content
60
+ // type, and the disposition that makes the browser save it under the name the
61
+ // service chose. Everything else (cache-control, cookies, ...) has no meaning
62
+ // on a static host.
63
+ const RESPONSE_HEADERS = ['content-type', 'content-disposition']
64
+
65
+ /**
66
+ * Parses what the service wrote. A header value is a byte string, so a value up
67
+ * to U+00FF is passed through as written - the same bytes node writes when the
68
+ * server sets the same header, which is what the mock has to match. HTAB is
69
+ * allowed inside a field value by HTTP and the server passes it through, so it
70
+ * is part of the set.
71
+ *
72
+ * 0x7F and 0x80-0x9F are outside it: the browser's Headers implementation strips
73
+ * those bytes from a value without complaining, which would save a download
74
+ * under a name with characters missing, so such a value is refused instead and
75
+ * the reply keeps its default delivery. CR, LF and NUL are refused for the same
76
+ * reason - they make the Response constructor throw.
77
+ *
78
+ * A filename outside ASCII belongs in RFC 6266 form
79
+ * (`filename*=UTF-8''r%C3%A9sum%C3%A9.csv`) - raw non-ASCII bytes in a header
80
+ * are not portable, on this host or on the server.
81
+ */
82
+ const HEADER_VALUE = /^[\x09\x20-\x7E\xA0-\xFF]+$/
83
+
84
+ const parseHeaders = (content) => {
85
+ const headers = {}
86
+ String(content || '')
87
+ .split('\n')
88
+ .map((line) => line.trim())
89
+ .filter(Boolean)
90
+ .forEach((line) => {
91
+ const separator = line.indexOf(':')
92
+ if (separator < 1) return
93
+ const name = line.slice(0, separator).trim().toLowerCase()
94
+ const value = line.slice(separator + 1).trim()
95
+ if (!RESPONSE_HEADERS.includes(name)) return
96
+ // the last line for a name wins, and a value that cannot be sent removes
97
+ // the header - the rule the server applies in extractHeaders
98
+ if (HEADER_VALUE.test(value)) headers[name] = value
99
+ else delete headers[name]
100
+ })
101
+ return headers
102
+ }
103
+
104
+ /**
105
+ * The reply a run produces: the webout, in the shape and under the name the
106
+ * service asked for. A debug reply is the webout framed with the log, so it
107
+ * stays text - honouring a download there would put the frame in the file.
108
+ */
109
+ const weboutResponse = (result, debug) => {
110
+ if (debug)
111
+ return text(
112
+ [result.webout, LOGS_SEPARATOR, result.log, LOGS_SEPARATOR].join('\n')
113
+ )
114
+
115
+ const headers = parseHeaders(result.headers)
116
+ if (result.binary) {
117
+ if (!headers['content-type'])
118
+ headers['content-type'] = 'application/octet-stream'
119
+ return new Response(toBytes(result.webout), { status: 200, headers })
120
+ }
121
+ return new Response(result.webout, { status: 200, headers })
122
+ }
123
+
43
124
  const broadcast = async (message) => {
44
125
  const clients = await self.clients.matchAll({ includeUncontrolled: true })
45
126
  clients.forEach((client) => client.postMessage(message))
@@ -105,7 +186,12 @@ const handle = async (request, url) => {
105
186
  }
106
187
 
107
188
  if (path.endsWith('/SASjsApi/drive/file') && request.method === 'GET') {
108
- return text(R.read(query._filePath || '').contents)
189
+ const file = R.read(query._filePath || '')
190
+ return file.binary
191
+ ? new Response(toBytes(file.contents), {
192
+ headers: { 'content-type': 'application/octet-stream' }
193
+ })
194
+ : text(file.contents)
109
195
  }
110
196
 
111
197
  if (
@@ -138,13 +224,7 @@ const handle = async (request, url) => {
138
224
  )
139
225
  }
140
226
  const debug = String(merged._debug || '').length > 0
141
- return text(
142
- debug
143
- ? [result.webout, LOGS_SEPARATOR, result.log, LOGS_SEPARATOR].join(
144
- '\n'
145
- )
146
- : result.webout
147
- )
227
+ return weboutResponse(result, debug)
148
228
  }
149
229
 
150
230
  if (path.includes('/SASjsApi/session')) {
@@ -219,9 +299,18 @@ self.addEventListener('message', (event) => {
219
299
 
220
300
  case 'sasjs-stats':
221
301
  event.waitUntil(
222
- R.ensureReady().then(() =>
223
- reply({ type: 'sasjs-stats', boot: R.bootInfo(), stats: R.stats() })
224
- )
302
+ R.ensureReady().then(() => {
303
+ try {
304
+ reply({ type: 'sasjs-stats', boot: R.bootInfo(), stats: R.stats() })
305
+ } catch (e) {
306
+ // a corrupt entry fails the whole total, so the page is answered
307
+ // with the reason instead of waiting out its own timeout
308
+ reply({
309
+ type: 'sasjs-stats',
310
+ error: (e && e.message) || String(e)
311
+ })
312
+ }
313
+ })
225
314
  )
226
315
  break
227
316
  }
@@ -10,6 +10,17 @@ export interface MockSiteResult {
10
10
  /** True when a captured drive snapshot was shipped with the site. */
11
11
  snapshot: boolean;
12
12
  }
13
+ /**
14
+ * The bounds on what the browser drive seeds. A byte entry is carried as base64
15
+ * in vfs-seed.json and held in the worker's memory, so a large artefact - an
16
+ * export, a media file, a vendored archive - makes the generated site
17
+ * unloadable: one file over the per-file bound, or enough files to pass the
18
+ * total, is left off the drive and named in a warning, as every non-text file
19
+ * was when the drive carried text only. Text has no bound: a program folder is
20
+ * text by nature and the drive has always carried it.
21
+ */
22
+ export declare const MAX_BINARY_SEED_BYTES: number;
23
+ export declare const MAX_SEED_BYTES: number;
13
24
  /**
14
25
  * Builds a static site that runs the project's JS services (its mocks) in the
15
26
  * browser, so a frontend can be tested with no server and no SAS.
@@ -75,6 +75,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
75
75
  return (mod && mod.__esModule) ? mod : { "default": mod };
76
76
  };
77
77
  Object.defineProperty(exports, "__esModule", { value: true });
78
+ exports.MAX_SEED_BYTES = exports.MAX_BINARY_SEED_BYTES = void 0;
78
79
  exports.buildMockSite = buildMockSite;
79
80
  var crypto_1 = require("crypto");
80
81
  var fs_1 = __importDefault(require("fs"));
@@ -140,28 +141,58 @@ var copyFolder = function (from, to) {
140
141
  */
141
142
  var PROGRAM_FOLDERS = ['services', 'jobs', 'tests'];
142
143
  /**
143
- * A drive file is a string in the browser runtime, so a file that does not
144
- * survive a UTF-8 round trip (an image, an archive, a compiled artefact) cannot
145
- * be carried onto it. Returning null for those keeps the site honest: the app
146
- * sees a missing file rather than mojibake it would happily write back.
144
+ * The bounds on what the browser drive seeds. A byte entry is carried as base64
145
+ * in vfs-seed.json and held in the worker's memory, so a large artefact - an
146
+ * export, a media file, a vendored archive - makes the generated site
147
+ * unloadable: one file over the per-file bound, or enough files to pass the
148
+ * total, is left off the drive and named in a warning, as every non-text file
149
+ * was when the drive carried text only. Text has no bound: a program folder is
150
+ * text by nature and the drive has always carried it.
147
151
  */
148
- var readTextFile = function (absolute) {
152
+ exports.MAX_BINARY_SEED_BYTES = 10 * 1024 * 1024;
153
+ exports.MAX_SEED_BYTES = 25 * 1024 * 1024;
154
+ /**
155
+ * What the drive stores for a file: its text when the bytes survive a UTF-8
156
+ * round trip, and behind the byte marker (BINARY_PREFIX in runtime-core.js)
157
+ * otherwise. An archive, an image or an xlsx therefore lands on the drive as
158
+ * bytes, so a service that downloads a project-supplied file finds it.
159
+ *
160
+ * Text that begins with U+0000 is escaped, because the runtime reads a leading
161
+ * NUL as its own marker - the same rule the runtime applies to a write.
162
+ */
163
+ var DRIVE_BINARY_PREFIX = '\u0000base64:';
164
+ var DRIVE_TEXT_PREFIX = '\u0000text:';
165
+ /** A byte count in MB, for the message that names a file too large to seed. */
166
+ var megabytes = function (bytes) {
167
+ return "".concat(Math.round((bytes / 1024 / 1024) * 10) / 10, " MB");
168
+ };
169
+ var readDriveFile = function (absolute) {
149
170
  var buffer = fs_1.default.readFileSync(absolute);
150
171
  var text = buffer.toString('utf8');
151
- return Buffer.from(text, 'utf8').equals(buffer) ? text : null;
172
+ if (!Buffer.from(text, 'utf8').equals(buffer))
173
+ return {
174
+ contents: DRIVE_BINARY_PREFIX + buffer.toString('base64'),
175
+ // the raw size, which is what the seed bounds are measured in
176
+ bytes: buffer.length
177
+ };
178
+ return {
179
+ contents: text.startsWith('\u0000') ? DRIVE_TEXT_PREFIX + text : text
180
+ };
152
181
  };
153
182
  var seedFromBuild = function (buildFolder, appLoc, exclude, excludeFolders) {
154
- var e_3, _a, e_4, _b;
183
+ var e_3, _a, e_4, _b, e_5, _c;
155
184
  if (exclude === void 0) { exclude = []; }
156
185
  if (excludeFolders === void 0) { excludeFolders = []; }
157
186
  var files = {};
158
187
  var skipped = [];
159
188
  var driveBase = "/sasjs/drive/files".concat(appLoc);
189
+ var seededBytes = 0;
160
190
  // Every folder of the build goes on the drive, so a sync folder's content (a
161
191
  // licence store, for instance) survives as well as the program folders.
162
192
  var directories = fs_1.default
163
193
  .readdirSync(buildFolder, { withFileTypes: true })
164
194
  .filter(function (entry) { return entry.isDirectory(); });
195
+ var candidates = [];
165
196
  try {
166
197
  for (var directories_1 = __values(directories), directories_1_1 = directories_1.next(); !directories_1_1.done; directories_1_1 = directories_1.next()) {
167
198
  var entry = directories_1_1.value;
@@ -172,22 +203,18 @@ var seedFromBuild = function (buildFolder, appLoc, exclude, excludeFolders) {
172
203
  var key = "".concat(driveBase, "/").concat(entry.name, "/").concat(relative);
173
204
  if (exclude.some(function (prefix) { return key.startsWith(prefix); }))
174
205
  return "continue";
175
- var contents = readTextFile(path_1.default.join(folder, relative));
176
- if (contents === null)
177
- skipped.push(key);
178
- else
179
- files[key] = contents;
206
+ candidates.push({ key: key, absolute: path_1.default.join(folder, relative) });
180
207
  };
181
208
  try {
182
- for (var _c = (e_4 = void 0, __values(listFiles(folder))), _d = _c.next(); !_d.done; _d = _c.next()) {
183
- var relative = _d.value;
209
+ for (var _d = (e_4 = void 0, __values(listFiles(folder))), _e = _d.next(); !_e.done; _e = _d.next()) {
210
+ var relative = _e.value;
184
211
  _loop_1(relative);
185
212
  }
186
213
  }
187
214
  catch (e_4_1) { e_4 = { error: e_4_1 }; }
188
215
  finally {
189
216
  try {
190
- if (_d && !_d.done && (_b = _c.return)) _b.call(_c);
217
+ if (_e && !_e.done && (_b = _d.return)) _b.call(_d);
191
218
  }
192
219
  finally { if (e_4) throw e_4.error; }
193
220
  }
@@ -200,6 +227,39 @@ var seedFromBuild = function (buildFolder, appLoc, exclude, excludeFolders) {
200
227
  }
201
228
  finally { if (e_3) throw e_3.error; }
202
229
  }
230
+ // The budget is spent in path order, not in whatever order the folder listing
231
+ // returns, so the same project yields the same drive - and the same warning -
232
+ // on every machine and filesystem.
233
+ candidates.sort(function (first, second) {
234
+ return first.key < second.key ? -1 : first.key > second.key ? 1 : 0;
235
+ });
236
+ try {
237
+ for (var candidates_1 = __values(candidates), candidates_1_1 = candidates_1.next(); !candidates_1_1.done; candidates_1_1 = candidates_1.next()) {
238
+ var candidate = candidates_1_1.value;
239
+ var file = readDriveFile(candidate.absolute);
240
+ // a byte entry counts against both bounds; a text entry counts against
241
+ // neither, because the drive has always carried the program folders
242
+ if (file.bytes !== undefined) {
243
+ if (file.bytes > exports.MAX_BINARY_SEED_BYTES) {
244
+ skipped.push({ key: candidate.key, bytes: file.bytes, limit: 'file' });
245
+ continue;
246
+ }
247
+ if (seededBytes + file.bytes > exports.MAX_SEED_BYTES) {
248
+ skipped.push({ key: candidate.key, bytes: file.bytes, limit: 'total' });
249
+ continue;
250
+ }
251
+ seededBytes += file.bytes;
252
+ }
253
+ files[candidate.key] = file.contents;
254
+ }
255
+ }
256
+ catch (e_5_1) { e_5 = { error: e_5_1 }; }
257
+ finally {
258
+ try {
259
+ if (candidates_1_1 && !candidates_1_1.done && (_c = candidates_1.return)) _c.call(candidates_1);
260
+ }
261
+ finally { if (e_5) throw e_5.error; }
262
+ }
203
263
  return { files: files, skipped: skipped };
204
264
  };
205
265
  /**
@@ -282,7 +342,7 @@ var patchIndexHtml = function (html, version) {
282
342
  function buildMockSite(target) {
283
343
  return __awaiter(this, void 0, void 0, function () {
284
344
  var projectDir, appLocRaw, appLoc, buildFolder, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, driveEntries, webFileCount, hostFolder, _b, _c, asset, hostVersion, swPath, snapshotSource, hasSnapshot, indexPath, _d, patched, warnings;
285
- var e_5, _e;
345
+ var e_6, _e;
286
346
  var _f, _g, _h;
287
347
  return __generator(this, function (_j) {
288
348
  switch (_j.label) {
@@ -332,8 +392,15 @@ function buildMockSite(target) {
332
392
  'Build a target whose services are JavaScript (typically a mocks project).');
333
393
  }
334
394
  if (skipped.length) {
335
- (_h = process.logger) === null || _h === void 0 ? void 0 : _h.warn("Left ".concat(skipped.length, " non-text file(s) off the mock drive, which holds text files only: ") +
336
- "".concat(skipped.slice(0, 3).join(', ')).concat(skipped.length > 3 ? ', ...' : ''));
395
+ (_h = process.logger) === null || _h === void 0 ? void 0 : _h.warn("Left ".concat(skipped.length, " file(s) off the mock drive, over the ").concat(megabytes(exports.MAX_BINARY_SEED_BYTES), " per-file or ").concat(megabytes(exports.MAX_SEED_BYTES), " total the browser build seeds: ") +
396
+ "".concat(skipped
397
+ .slice(0, 3)
398
+ .map(function (file) {
399
+ return "".concat(file.key, " (").concat(megabytes(file.bytes), ", ").concat(file.limit === 'file'
400
+ ? 'over the per-file limit'
401
+ : 'over the drive budget', ")");
402
+ })
403
+ .join(', ')).concat(skipped.length > 3 ? ', ...' : ''));
337
404
  }
338
405
  // Only now is the previous site expendable.
339
406
  fs_1.default.rmSync(outputFolder, { recursive: true, force: true });
@@ -354,12 +421,12 @@ function buildMockSite(target) {
354
421
  fs_1.default.copyFileSync(path_1.default.join(ASSETS_FOLDER, asset), path_1.default.join(hostFolder, asset));
355
422
  }
356
423
  }
357
- catch (e_5_1) { e_5 = { error: e_5_1 }; }
424
+ catch (e_6_1) { e_6 = { error: e_6_1 }; }
358
425
  finally {
359
426
  try {
360
427
  if (_c && !_c.done && (_e = _b.return)) _e.call(_b);
361
428
  }
362
- finally { if (e_5) throw e_5.error; }
429
+ finally { if (e_6) throw e_6.error; }
363
430
  }
364
431
  // sw.js sits at the site root: a worker can only control the folder it is
365
432
  // served from, and the app has to be controlled from /
@@ -228,23 +228,115 @@ describe('browser-hosted build', function () {
228
228
  }
229
229
  });
230
230
  }); });
231
- it('leaves non-text files off the drive instead of corrupting them', function () { return __awaiter(void 0, void 0, void 0, function () {
231
+ it('carries a non-text file onto the drive as bytes', function () { return __awaiter(void 0, void 0, void 0, function () {
232
232
  var png, result, seed;
233
+ return __generator(this, function (_a) {
234
+ switch (_a.label) {
235
+ case 0:
236
+ png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
237
+ write(path_1.default.join(root, 'sasjsbuild', 'mock-storage', 'fixtures.json'), '{}');
238
+ fs_1.default.writeFileSync(path_1.default.join(root, 'sasjsbuild', 'mock-storage', 'logo.png'), png);
239
+ // text that begins with the marker's own NUL is escaped, so it cannot be
240
+ // mistaken for a byte entry
241
+ fs_1.default.writeFileSync(path_1.default.join(root, 'sasjsbuild', 'mock-storage', 'odd.txt'), '\u0000base64:not really');
242
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
243
+ case 1:
244
+ result = _a.sent();
245
+ seed = readSeed();
246
+ // stored behind the marker the runtime decodes, and the text files beside it
247
+ // stay text
248
+ expect(seed.files["/sasjs/drive/files".concat(appLoc, "/mock-storage/logo.png")]).toBe('\u0000base64:' + png.toString('base64'));
249
+ expect(seed.files["/sasjs/drive/files".concat(appLoc, "/mock-storage/fixtures.json")]).toBe('{}');
250
+ expect(seed.files["/sasjs/drive/files".concat(appLoc, "/mock-storage/odd.txt")]).toBe('\u0000text:\u0000base64:not really');
251
+ expect(result.driveEntries).toBe(7); // 3 programs + 3 text files + the png
252
+ return [2 /*return*/];
253
+ }
254
+ });
255
+ }); });
256
+ it('leaves a byte file over the seed bound off the drive, and names it', function () { return __awaiter(void 0, void 0, void 0, function () {
257
+ var result, seed;
233
258
  var _a;
234
259
  return __generator(this, function (_b) {
235
260
  switch (_b.label) {
236
261
  case 0:
237
- png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
238
262
  write(path_1.default.join(root, 'sasjsbuild', 'mock-storage', 'fixtures.json'), '{}');
239
- fs_1.default.writeFileSync(path_1.default.join(root, 'sasjsbuild', 'mock-storage', 'logo.png'), png);
263
+ fs_1.default.writeFileSync(path_1.default.join(root, 'sasjsbuild', 'mock-storage', 'export.zip'), Buffer.alloc(mockSite_1.MAX_BINARY_SEED_BYTES + 1, 0x89));
240
264
  return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
241
265
  case 1:
242
266
  result = _b.sent();
243
267
  seed = readSeed();
244
- expect(Object.keys(seed.files).some(function (k) { return k.endsWith('logo.png'); })).toBe(false);
245
- expect(seed.files["/sasjs/drive/files".concat(appLoc, "/mock-storage/fixtures.json")]).toBe('{}');
268
+ // one large artefact must not make the generated site unloadable
269
+ expect(seed.files["/sasjs/drive/files".concat(appLoc, "/mock-storage/export.zip")]).toBeUndefined();
246
270
  expect(result.driveEntries).toBe(5); // 3 programs + 2 text files
247
- expect((_a = process.logger) === null || _a === void 0 ? void 0 : _a.warn).toHaveBeenCalledWith(expect.stringContaining('logo.png'));
271
+ expect((_a = process.logger) === null || _a === void 0 ? void 0 : _a.warn).toHaveBeenCalledWith(expect.stringContaining('export.zip (10 MB'));
272
+ return [2 /*return*/];
273
+ }
274
+ });
275
+ }); });
276
+ it('stops seeding bytes once the drive budget is spent, and names the file', function () { return __awaiter(void 0, void 0, void 0, function () {
277
+ var chunk, _a, _b, name_1, seed, seeded;
278
+ var e_2, _c;
279
+ var _d;
280
+ return __generator(this, function (_e) {
281
+ switch (_e.label) {
282
+ case 0:
283
+ write(path_1.default.join(root, 'sasjsbuild', 'mock-storage', 'fixtures.json'), '{}');
284
+ chunk = Buffer.alloc(Math.floor(mockSite_1.MAX_BINARY_SEED_BYTES * 0.9), 0x89);
285
+ try {
286
+ for (_a = __values(['one.bin', 'two.bin', 'three.bin']), _b = _a.next(); !_b.done; _b = _a.next()) {
287
+ name_1 = _b.value;
288
+ fs_1.default.writeFileSync(path_1.default.join(root, 'sasjsbuild', 'mock-storage', name_1), chunk);
289
+ }
290
+ }
291
+ catch (e_2_1) { e_2 = { error: e_2_1 }; }
292
+ finally {
293
+ try {
294
+ if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
295
+ }
296
+ finally { if (e_2) throw e_2.error; }
297
+ }
298
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())
299
+ // the budget is spent in path order, so the drive is the same on every host:
300
+ // one and three fit, two does not
301
+ ];
302
+ case 1:
303
+ _e.sent();
304
+ seed = readSeed();
305
+ seeded = Object.keys(seed.files)
306
+ .filter(function (key) { return key.endsWith('.bin'); })
307
+ .map(function (key) { return key.slice(key.lastIndexOf('/') + 1); });
308
+ expect(seeded).toEqual(['one.bin', 'three.bin']);
309
+ expect((_d = process.logger) === null || _d === void 0 ? void 0 : _d.warn).toHaveBeenCalledWith(expect.stringContaining('two.bin (9 MB, over the drive budget)'));
310
+ return [2 /*return*/];
311
+ }
312
+ });
313
+ }); });
314
+ it('seeds a project file the runtime reads back as its bytes', function () { return __awaiter(void 0, void 0, void 0, function () {
315
+ var png, runtime, served;
316
+ return __generator(this, function (_a) {
317
+ switch (_a.label) {
318
+ case 0:
319
+ png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
320
+ write(path_1.default.join(root, 'sasjsbuild', 'mock-storage', 'fixtures.json'), '{}');
321
+ fs_1.default.writeFileSync(path_1.default.join(root, 'sasjsbuild', 'mock-storage', 'logo.png'), png);
322
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())
323
+ // the seed is only useful if the runtime decodes what the build wrote
324
+ ];
325
+ case 1:
326
+ _a.sent();
327
+ global.self = {};
328
+ jest.resetModules();
329
+ runtime = require('../assets/runtime-core.js');
330
+ runtime.load(readSeed().files);
331
+ served = runtime.run({
332
+ code: [
333
+ "const file = '/sasjs/drive/files".concat(appLoc, "/mock-storage/logo.png'"),
334
+ "_webout = [fs.readFileSync(file).toString('hex'), fs.statSync(file).size].join('|')"
335
+ ].join('\n')
336
+ });
337
+ delete global.self;
338
+ expect(served.error).toBeNull();
339
+ expect(served.webout).toBe("".concat(png.toString('hex'), "|").concat(png.length));
248
340
  return [2 /*return*/];
249
341
  }
250
342
  });
@@ -355,8 +447,8 @@ describe('browser-hosted build', function () {
355
447
  });
356
448
  }); });
357
449
  it('refuses a mockWebFolder that would delete the project or its inputs', function () { return __awaiter(void 0, void 0, void 0, function () {
358
- var _a, _b, mockWebFolder, e_2_1;
359
- var e_2, _c;
450
+ var _a, _b, mockWebFolder, e_3_1;
451
+ var e_3, _c;
360
452
  return __generator(this, function (_d) {
361
453
  switch (_d.label) {
362
454
  case 0:
@@ -387,14 +479,14 @@ describe('browser-hosted build', function () {
387
479
  return [3 /*break*/, 1];
388
480
  case 4: return [3 /*break*/, 7];
389
481
  case 5:
390
- e_2_1 = _d.sent();
391
- e_2 = { error: e_2_1 };
482
+ e_3_1 = _d.sent();
483
+ e_3 = { error: e_3_1 };
392
484
  return [3 /*break*/, 7];
393
485
  case 6:
394
486
  try {
395
487
  if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
396
488
  }
397
- finally { if (e_2) throw e_2.error; }
489
+ finally { if (e_3) throw e_3.error; }
398
490
  return [7 /*endfinally*/];
399
491
  case 7:
400
492
  expect(fs_1.default.existsSync(path_1.default.join(root, 'client', 'dist', 'index.html'))).toBe(true);
@@ -279,9 +279,8 @@ describe('page-side store', function () {
279
279
  return [4 /*yield*/, window.SASjsMock.writeFiles({ '/sasjs/drive/b.txt': 'bb' })];
280
280
  case 5:
281
281
  _a.sent();
282
- return [4 /*yield*/, expect(window.SASjsMock.stats()).resolves.toEqual({
283
- files: 1,
284
- bytes: 2
282
+ return [4 /*yield*/, expect(window.SASjsMock.readAll()).resolves.toEqual({
283
+ '/sasjs/drive/b.txt': 'bb'
285
284
  })];
286
285
  case 6:
287
286
  _a.sent();
@@ -295,6 +294,65 @@ describe('page-side store', function () {
295
294
  }
296
295
  });
297
296
  }); });
297
+ it('takes the drive total from the worker, which owns the storage format', function () { return __awaiter(void 0, void 0, void 0, function () {
298
+ var _a, window, posted, reply, pending;
299
+ return __generator(this, function (_b) {
300
+ switch (_b.label) {
301
+ case 0:
302
+ _a = loadPersist(), window = _a.window, posted = _a.posted, reply = _a.reply;
303
+ pending = window.SASjsMock.stats();
304
+ return [4 /*yield*/, Promise.resolve()];
305
+ case 1:
306
+ _b.sent();
307
+ expect(posted).toContainEqual({ type: 'sasjs-stats' });
308
+ // the worker measures a byte entry as its bytes, not as base64
309
+ reply({ type: 'sasjs-stats', boot: {}, stats: { files: 2, bytes: 7 } });
310
+ return [4 /*yield*/, expect(pending).resolves.toEqual({ files: 2, bytes: 7 })];
311
+ case 2:
312
+ _b.sent();
313
+ return [2 /*return*/];
314
+ }
315
+ });
316
+ }); });
317
+ it('fails the read when the worker cannot report, rather than returning NaN', function () { return __awaiter(void 0, void 0, void 0, function () {
318
+ var window, pending;
319
+ return __generator(this, function (_a) {
320
+ switch (_a.label) {
321
+ case 0:
322
+ jest.useFakeTimers();
323
+ window = loadPersist().window;
324
+ pending = expect(window.SASjsMock.stats()).rejects.toThrow('timed out waiting for sasjs-stats');
325
+ return [4 /*yield*/, jest.advanceTimersByTimeAsync(20000)];
326
+ case 1:
327
+ _a.sent();
328
+ return [4 /*yield*/, pending];
329
+ case 2:
330
+ _a.sent();
331
+ return [2 /*return*/];
332
+ }
333
+ });
334
+ }); });
335
+ it('fails the read when the reply carries no stats', function () { return __awaiter(void 0, void 0, void 0, function () {
336
+ var _a, window, reply, pending;
337
+ return __generator(this, function (_b) {
338
+ switch (_b.label) {
339
+ case 0:
340
+ _a = loadPersist(), window = _a.window, reply = _a.reply;
341
+ pending = expect(window.SASjsMock.stats()).rejects.toThrow('carried no stats');
342
+ return [4 /*yield*/, Promise.resolve()
343
+ // a worker left over from an older build answers the message with no stats
344
+ ];
345
+ case 1:
346
+ _b.sent();
347
+ // a worker left over from an older build answers the message with no stats
348
+ reply({ type: 'sasjs-stats' });
349
+ return [4 /*yield*/, pending];
350
+ case 2:
351
+ _b.sent();
352
+ return [2 /*return*/];
353
+ }
354
+ });
355
+ }); });
298
356
  it('writes nothing when a change set is empty', function () { return __awaiter(void 0, void 0, void 0, function () {
299
357
  var window;
300
358
  return __generator(this, function (_a) {