@sasjs/cli 6.27.3 → 6.29.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.
@@ -97,15 +97,20 @@
97
97
  }
98
98
  }
99
99
 
100
- const respond = (xhr, status, contentType, body) => {
100
+ const respond = (xhr, status, contentType, body, bytes) => {
101
101
  Object.defineProperty(xhr, 'status', { value: status, configurable: true })
102
102
  Object.defineProperty(xhr, 'statusText', {
103
103
  value: status === 200 ? 'OK' : 'ERROR',
104
104
  configurable: true
105
105
  })
106
106
  Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true })
107
+ // `bytes` is set when the caller asked for a blob or an arraybuffer. The
108
+ // drive serves PNGs, PDFs and workbooks, and answering a blob request with
109
+ // the body decoded as text hands `<img>` mojibake - which is how a picture
110
+ // on the site came to fail to render with an "Abort" modal.
111
+ const binary = bytes !== undefined
107
112
  Object.defineProperty(xhr, 'responseText', {
108
- value: body,
113
+ value: binary ? '' : body,
109
114
  configurable: true
110
115
  })
111
116
  Object.defineProperty(xhr, 'responseURL', {
@@ -113,7 +118,16 @@
113
118
  configurable: true
114
119
  })
115
120
  Object.defineProperty(xhr, 'response', {
116
- value: xhr.responseType === 'json' ? safeParse(body) : body,
121
+ value: binary
122
+ ? xhr.responseType === 'arraybuffer'
123
+ ? bytes.buffer.slice(
124
+ bytes.byteOffset,
125
+ bytes.byteOffset + bytes.byteLength
126
+ )
127
+ : new Blob([bytes], { type: contentType })
128
+ : xhr.responseType === 'json'
129
+ ? safeParse(body)
130
+ : body,
117
131
  configurable: true
118
132
  })
119
133
  xhr.getAllResponseHeaders = () => 'content-type: ' + contentType + '\r\n'
@@ -165,8 +179,30 @@
165
179
  )
166
180
 
167
181
  const res = await fetch(xhr.__sasjsMockUrl, init)
168
- const text = await res.text()
169
182
  const contentType = res.headers.get('content-type') || 'text/plain'
183
+
184
+ // A caller that asked for bytes must be handed them as bytes: the same
185
+ // request answered with `res.text()` gives a PNG's UTF-8 damage, and the
186
+ // image or file the caller builds from it is unusable.
187
+ const wantsBytes =
188
+ xhr.responseType === 'blob' || xhr.responseType === 'arraybuffer'
189
+
190
+ if (wantsBytes) {
191
+ const bytes = new Uint8Array(await res.arrayBuffer())
192
+ if (res.status >= 400)
193
+ console.error(
194
+ '[sasjs-mock] ' +
195
+ xhr.__sasjsMockUrl +
196
+ ' -> ' +
197
+ res.status +
198
+ ' ' +
199
+ new TextDecoder().decode(bytes).slice(0, 400)
200
+ )
201
+ respond(xhr, res.status, contentType, '', bytes)
202
+ return
203
+ }
204
+
205
+ const text = await res.text()
170
206
  if (res.status >= 400)
171
207
  console.error(
172
208
  '[sasjs-mock] ' +
@@ -934,6 +934,32 @@ const formatArg = (a) => {
934
934
 
935
935
  const PROGRAM_FOLDERS = ['services', 'jobs', 'tests']
936
936
 
937
+ /**
938
+ * The extensions an extension-less program is tried with, in the host's `RUN_TIMES` order.
939
+ *
940
+ * SASjs Server gives priority to the run time that comes first in `RUN_TIMES`
941
+ * (`docs/pages/settings.md`): with `RUN_TIMES=js,sas` a reference to `/some/program` finds
942
+ * `program.js` before `program.sas`. The order is the host's to declare, so this starts empty
943
+ * and the host sets it: guessing here would let a host that says nothing run a file its own
944
+ * `RUN_TIMES` does not offer.
945
+ */
946
+ let RUN_TIME_EXTENSIONS = []
947
+
948
+ /** Declare the host's run times, in its order. Their extensions are tried in that order. */
949
+ const setRunTimes = (runTimes) => {
950
+ const extensions = (Array.isArray(runTimes) ? runTimes : [])
951
+ .map(
952
+ (runTime) =>
953
+ '.' +
954
+ String(runTime || '')
955
+ .trim()
956
+ .toLowerCase()
957
+ )
958
+ .filter((extension) => extension.length > 1)
959
+
960
+ if (extensions.length) RUN_TIME_EXTENSIONS = extensions
961
+ }
962
+
937
963
  /**
938
964
  * Resolves a program reference the way the server does: an exact drive path,
939
965
  * with the runtime extension tried. The adapter sends a full path, and prepends
@@ -960,7 +986,10 @@ const resolveProgram = (program) => {
960
986
  }
961
987
 
962
988
  for (const root of roots) {
963
- for (const candidate of [root, root + '.js', root + '.sas']) {
989
+ for (const candidate of [
990
+ root,
991
+ ...RUN_TIME_EXTENSIONS.map((extension) => root + extension)
992
+ ]) {
964
993
  if (FILES.has(candidate)) return candidate
965
994
  }
966
995
  }
@@ -1528,6 +1557,8 @@ const api = {
1528
1557
  // the file a program names, so the host can tell a .sas program from a .js one
1529
1558
  // and refuse the former instead of compiling SAS source as JavaScript
1530
1559
  resolveProgram: (program) => resolveProgram(program),
1560
+ // the host's RUN_TIMES, so an extension-less program is probed in the order it declares
1561
+ setRunTimes: (runTimes) => setRunTimes(runTimes),
1531
1562
  // the drive's bytes, for @sasjs/api's services: the rules that decide a
1532
1563
  // status are the package's, so this host hands over storage and nothing else
1533
1564
  driveStore,
@@ -401,21 +401,83 @@ const persistChanges = async () => {
401
401
  /**
402
402
  * A recorded SAS session, for a project that ships one.
403
403
  *
404
- * A browser has no SAS binary. Rather than let a Run on a .sas program be a
405
- * surprise, a project may record a session once and commit it as
406
- * `sas-transcript.json`: the build copies it into the site and this answers a
407
- * run of a program it names with that session's log, listing and webout. The
408
- * recording is the demo's own data, so nothing here is invented for a project
409
- * that does not ship one - those runs reach the runner and are refused exactly
410
- * as before.
404
+ * A mock site is a JavaScript backend: there is no SAS binary here and never was, so a SAS
405
+ * run cannot be executed and cannot fail for want of the binary either. It answers with a
406
+ * session - the log, listing and webout a run of that program would have produced.
407
+ *
408
+ * By default that session is the mocked one below, which is the point of the mock: a visitor
409
+ * who edits a `.sas` file or runs a buffer sees the Studio's log panel the way a real run
410
+ * fills it, errors and warnings included, instead of being told about a binary a browser was
411
+ * never going to have.
412
+ *
413
+ * A project may record a real session and commit it as `sas-transcript.json`, and a run of a
414
+ * program it names is answered with that instead - the project's own output is better than
415
+ * ours, and a demo of a specific report wants the real numbers.
411
416
  *
412
417
  * Shape: `{ "<program path>": {"log": "...", "print": "...", "webout": "..."} }`,
413
418
  * all three strings. The path is matched as written apart from the
414
419
  * `/sasjs/drive/files` prefix and the `.sas` suffix, so a recording keeps the
415
420
  * path a person sees; a bare file name is not enough, because that would answer
416
- * every file of that name in the drive.
421
+ * every file of that name in the drive. `{{program}}` in any of the three strings is
422
+ * replaced with the program's logical path, so an answer can name what was run.
417
423
  */
418
424
  const SAS_TRANSCRIPT = 'host/sas-transcript.json'
425
+
426
+ /**
427
+ * The session a SAS run is answered with when the project has not recorded one.
428
+ *
429
+ * Written the way a SAS log is written - source lines with their numbers, notes, a warning and
430
+ * an error, the process-time block - because the Studio parses this text: the log panel marks
431
+ * errors and warnings from it, and a demo whose mock answered with an empty log would show a
432
+ * panel that never lights up. The webout is what a service that sends data back would have
433
+ * written, and the listing is what `proc print` puts on a page.
434
+ */
435
+ const MOCKED_SESSION = {
436
+ webout: '{"SYSSITE":"00000000","MOCKED":true}',
437
+ log: [
438
+ 'NOTE: This is a mocked SAS session: a browser has no SAS binary.',
439
+ ' The program below is {{program}}.',
440
+ '',
441
+ '1 data work.class;',
442
+ '2 set sashelp.class;',
443
+ '3 where age > 12',
444
+ '4 by name;',
445
+ '5 run;',
446
+ '',
447
+ 'ERROR 22-322: Syntax error, expecting one of the following: ;, DATA, DATALINES,',
448
+ ' DELETE, DESCRIBE, FILE, INPUT, LIST, MERGE, MODIFY, OUTPUT, PUT,',
449
+ ' SET, UPDATE.',
450
+ 'ERROR 76-322: Syntax error, statement will be ignored.',
451
+ '',
452
+ 'NOTE: The SAS System stopped processing this step because of errors.',
453
+ 'WARNING: The data set WORK.CLASS may be incomplete. When this step was stopped',
454
+ ' there were 0 observations and 0 variables.',
455
+ 'NOTE: DATA statement used (Total process time):',
456
+ ' real time 0.01 seconds',
457
+ ' cpu time 0.01 seconds',
458
+ '',
459
+ '6 proc print data=sashelp.class (obs=3);',
460
+ '7 var name age height weight;',
461
+ '8 run;',
462
+ '',
463
+ 'NOTE: There were 3 observations read from the data set SASHELP.CLASS.',
464
+ 'NOTE: The PROCEDURE PRINT printed page 1.',
465
+ 'NOTE: PROCEDURE PRINT used (Total process time):',
466
+ ' real time 0.02 seconds',
467
+ ' cpu time 0.02 seconds',
468
+ '',
469
+ 'NOTE: The SAS System stopped processing this step because of errors.',
470
+ 'NOTE: SAS Institute Inc., SAS Campus Drive, Cary, NC USA 27513-2414'
471
+ ].join('\n'),
472
+ print: [
473
+ 'The SAS System',
474
+ '',
475
+ ' Obs Name Age Height Weight',
476
+ ' 1 Alfred 14 69.0 112.5',
477
+ ' 2 Alice 13 56.5 84.0',
478
+ ' 3 Barbara 13 65.3 98.0'
479
+ ].join('\n')
480
+ }
419
481
  let TRANSCRIPT = null
420
482
 
421
483
  const transcript = async () => {
@@ -444,17 +506,62 @@ const logicalPath = (value) =>
444
506
  .replace(/^\/sasjs\/drive\/files/i, '')
445
507
  .replace(/\.sas$/i, '')
446
508
 
447
- /** The recording naming this program, if the project shipped one. */
509
+ /** A recording with its placeholders filled in, so a demo can name what was run. */
510
+ const withProgram = (recording, requested) => {
511
+ // the replacement is a function, not a string: a program path may hold `$&` or `$1`, which a
512
+ // string replacement expands, so a caller naming `services/$&x` would be answered with
513
+ // `{{program}}` in place of their own path
514
+ const fill = (value) =>
515
+ String(value || '').replaceAll(
516
+ '{{program}}',
517
+ () => requested || 'the code in this buffer'
518
+ )
519
+
520
+ return {
521
+ webout: fill(recording.webout),
522
+ log: fill(recording.log),
523
+ print: fill(recording.print)
524
+ }
525
+ }
526
+
527
+ /**
528
+ * The session this SAS run is answered with.
529
+ *
530
+ * The project's recording of that program if it shipped one, and the mocked session otherwise -
531
+ * never a refusal, and never nothing. A SAS run is only reached here when the host cannot
532
+ * execute it, which in a browser is always, so there is no case in which the honest answer is
533
+ * "no".
534
+ */
448
535
  const transcriptFor = async (programPath) => {
449
536
  const requested = logicalPath(programPath)
450
- if (!requested) return undefined
451
-
452
537
  const recorded = await transcript()
453
538
 
454
- for (const key of Object.keys(recorded))
455
- if (logicalPath(key) === requested) return recorded[key]
539
+ if (requested)
540
+ for (const key of Object.keys(recorded))
541
+ if (logicalPath(key) === requested)
542
+ return withProgram(recorded[key], requested)
543
+
544
+ return withProgram(MOCKED_SESSION, requested)
545
+ }
546
+
547
+ /**
548
+ * Whether this request is a SAS run.
549
+ *
550
+ * A code run states its run time. A program run does not - the server resolves it against
551
+ * `RUN_TIMES`, which is why `R.setRunTimes` puts that order into the runtime-core resolution -
552
+ * and the file that comes back decides. It is the same resolution the runner makes, so the two cannot disagree about
553
+ * which runs are SAS: a rule of my own here would sooner or later answer a run the runner
554
+ * would have executed, or the reverse.
555
+ */
556
+ const isSasRun = (code, runTime, program) => {
557
+ // a code run always states its run time - the package refuses one without it - so this is
558
+ // a plain comparison rather than a default
559
+ if (code) return String(runTime || '').toLowerCase() === 'sas'
560
+
561
+ // the request may name the program without its extension
562
+ const resolved = R.resolveProgram(program)
456
563
 
457
- return undefined
564
+ return Boolean(resolved && /\.sas$/i.test(resolved))
458
565
  }
459
566
 
460
567
  /**
@@ -488,17 +595,6 @@ const recordedProgramIn = (code, runTime) => {
488
595
  }
489
596
 
490
597
  // ---------------------------------------------------------------- the runner
491
- /**
492
- * What a SAS run this host cannot answer is told.
493
- *
494
- * `sas` is one of the host's run times, so the message must not claim the run
495
- * time is missing: what is missing is the binary, because this is a browser. It
496
- * names the one thing that does answer SAS here, so a reader whose program is
497
- * refused knows why the demo's own program is not.
498
- */
499
- const SAS_UNAVAILABLE =
500
- 'SAS is not available on this host: a browser has no SAS binary, and this demo answers only the program it ships a recorded session of.'
501
-
502
598
  /**
503
599
  * The work a run does, over the browser's drive.
504
600
  *
@@ -518,28 +614,43 @@ class BrowserRuntime extends API.MemorySessionHost {
518
614
  const program = request.program || {}
519
615
  const params = Object.assign({}, request.vars || {})
520
616
 
521
- // SAS never reaches the runner. `sas` is one of this host's run times - the
522
- // recording answers the program it names - but the answer for anything else
523
- // is the refusal a client already understands, not a JavaScript SyntaxError
524
- // quoting the SAS source, which is what handing it to the runner would
525
- // produce. The file is resolved because the request may name the program
526
- // without its extension.
527
617
  const resolved =
528
618
  program.code === undefined ? R.resolveProgram(program.path) : undefined
529
619
  const isSas =
530
620
  String(request.runTime || '').toLowerCase() === 'sas' ||
531
621
  Boolean(resolved && /\.sas$/i.test(resolved))
532
622
 
533
- if (isSas) throw runFailure(SAS_UNAVAILABLE)
623
+ // A SAS run is answered with its session, here as on the run routes: those answer before the
624
+ // runtime is reached, but a *trigger* goes through it, and a mock must not be the one place
625
+ // that refuses a run for want of a binary it never had. Handing SAS source to the JavaScript
626
+ // runner would be worse still - a SyntaxError quoting the source.
627
+ let artifact
628
+ if (isSas) {
629
+ const recording = await transcriptFor(
630
+ program.code !== undefined
631
+ ? recordedProgramIn(program.code, request.runTime)
632
+ : program.path
633
+ )
534
634
 
535
- const payload = { params, tables: tablesOf(request.files) }
536
- if (program.code !== undefined) payload.code = program.code
537
- else payload.program = program.path
635
+ artifact = artifactOf(
636
+ {
637
+ webout: recording.webout || '',
638
+ log: recording.log || '',
639
+ print: recording.print || '',
640
+ headers: SEEDED_HEADER_LINE
641
+ },
642
+ params._debug !== undefined
643
+ )
644
+ } else {
645
+ const payload = { params, tables: tablesOf(request.files) }
646
+ if (program.code !== undefined) payload.code = program.code
647
+ else payload.program = program.path
538
648
 
539
- const result = R.run(payload)
540
- if (result.error) throw runFailure(result.error)
649
+ const result = R.run(payload)
650
+ if (result.error) throw runFailure(result.error)
541
651
 
542
- const artifact = artifactOf(result, params._debug !== undefined)
652
+ artifact = artifactOf(result, params._debug !== undefined)
653
+ }
543
654
  const session = request.session
544
655
  if (session) {
545
656
  const stamp = new Date().toISOString()
@@ -625,21 +736,26 @@ const withoutBase = (pathname) =>
625
736
 
626
737
  // ------------------------------------------------------------- what it can run
627
738
  /**
628
- * The run times this host answers, in the order a client should try them.
739
+ * The run times this host answers, in order of priority.
629
740
  *
630
- * `js` is executed here, in the page's own JavaScript engine. `sas` is answered
631
- * from the recorded session (`host/sas-transcript.json`) for the one program the
632
- * recording names; any other SAS program is refused by `SAS_UNAVAILABLE`, which
633
- * names the missing binary rather than a missing run time - a browser has none.
741
+ * `js` is executed here, in the page's own JavaScript engine. `sas` is answered from a session -
742
+ * the project's recording of that program (`host/sas-transcript.json`) when it ships one, and the
743
+ * mocked session otherwise. That holds on every route that runs one: a run, a code run, and a
744
+ * trigger, which reaches the runtime rather than the run route and is answered there.
634
745
  *
635
- * Both the `/SASjsApi/info` answer and the runtime service read this list, so the
636
- * run times the host states and the ones its service accepts cannot drift apart.
637
- * `sas` is stated because that is what offers Run on a `.sas` file at all; a
638
- * program the recording does not name is then refused with its reason, not by the
639
- * run time being absent from a list the host publishes.
746
+ * The order is the priority SASjs Server gives `RUN_TIMES` (`docs/pages/settings.md`): `js` leads
747
+ * here, because a browser executes JavaScript and holds a recording where a server has a binary.
748
+ * Everything else is derived from this list - the order an extension-less program is resolved in
749
+ * (`R.setRunTimes` below), and what `/SASjsApi/info` and the runtime service report, so the run
750
+ * times the host publishes and the ones its service accepts cannot drift apart. `sas` is stated
751
+ * because that is what offers Run on a `.sas` file at all.
640
752
  */
641
753
  const RUN_TIMES = ['js', 'sas']
642
754
 
755
+ // the order decides which program an extension-less reference runs, the way the server's
756
+ // RUN_TIMES does - `services/x` is `x.js` here, because `js` comes first
757
+ R.setRunTimes(RUN_TIMES)
758
+
643
759
  // ------------------------------------------------------------- host reports
644
760
  // The Admin routes ask a host what it is doing. A browser has no process, but it
645
761
  // does have a page, a heap and this worker's own activity, so the reports are
@@ -1211,36 +1327,34 @@ const runRoute = async (routeId, payload, subject) => {
1211
1327
  runRequest.program = query._program || body._program
1212
1328
  }
1213
1329
 
1214
- // A recorded SAS session is answered here, ahead of the run. `sas` IS one of
1215
- // this host's run times - it is what the Studio reads to offer Run on a .sas
1216
- // file - and the recording is how it is answered, so the check comes before
1217
- // the package would compile the source as JavaScript. A trigger is excluded:
1218
- // it has to answer with the session record the route builds, which a client
1219
- // parses as JSON, so a recording is not an answer there.
1220
- if (!trigger) {
1221
- const recorded = code
1222
- ? recordedProgramIn(body.code, body.runTime)
1223
- : runRequest.program
1224
- const recording = recorded ? await transcriptFor(recorded) : undefined
1225
-
1226
- if (recording)
1227
- return artifactResponse(
1228
- artifactOf(
1229
- {
1230
- webout: recording.webout || '',
1231
- log: recording.log || '',
1232
- print: recording.print || '',
1233
- // the seed every session starts with, so a run without `_debug`
1234
- // answers text/html rather than leaving the type to the browser -
1235
- // the same rule runtime-core applies to a service that declares none
1236
- headers: SEEDED_HEADER_LINE
1237
- },
1238
- // a code run carries no `_debug` and still answers with its log: the
1239
- // package compiles a code run at its own debug level, and the Studio
1240
- // splits the three parts on the separator it writes
1241
- code ? true : vars._debug !== undefined
1242
- )
1330
+ // A SAS run is answered here, ahead of the run: this host cannot execute one, so the answer
1331
+ // is a session - the project's recording of that program, or the mocked one. `sas` IS one of
1332
+ // this host's run times, which is what the Studio reads to offer Run on a `.sas` file, so the
1333
+ // check comes before the package would compile the source as JavaScript. A trigger is
1334
+ // excluded: it has to answer with the session record the route builds, which a client parses
1335
+ // as JSON, so a transcript is not an answer there.
1336
+ if (!trigger && isSasRun(code, body.runTime, runRequest.program)) {
1337
+ const recording = await transcriptFor(
1338
+ code ? recordedProgramIn(body.code, body.runTime) : runRequest.program
1339
+ )
1340
+
1341
+ return artifactResponse(
1342
+ artifactOf(
1343
+ {
1344
+ webout: recording.webout || '',
1345
+ log: recording.log || '',
1346
+ print: recording.print || '',
1347
+ // the seed every session starts with, so a run without `_debug`
1348
+ // answers text/html rather than leaving the type to the browser -
1349
+ // the same rule runtime-core applies to a service that declares none
1350
+ headers: SEEDED_HEADER_LINE
1351
+ },
1352
+ // a code run carries no `_debug` and still answers with its log: the
1353
+ // package compiles a code run at its own debug level, and the Studio
1354
+ // splits the three parts on the separator it writes
1355
+ code ? true : vars._debug !== undefined
1243
1356
  )
1357
+ )
1244
1358
  }
1245
1359
 
1246
1360
  if (trigger) {
@@ -275,6 +275,66 @@ describe('page-side bridge', function () {
275
275
  }
276
276
  });
277
277
  }); });
278
+ it('hands a blob request the bytes, not the body decoded as text', function () { return __awaiter(void 0, void 0, void 0, function () {
279
+ var bytes, XHR, xhr, readBack, _a;
280
+ return __generator(this, function (_b) {
281
+ switch (_b.label) {
282
+ case 0:
283
+ bytes = new Uint8Array([
284
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d
285
+ ]);
286
+ XHR = loadBridge(jest.fn(function () { return __awaiter(void 0, void 0, void 0, function () {
287
+ return __generator(this, function (_a) {
288
+ return [2 /*return*/, new Response(bytes, {
289
+ status: 200,
290
+ headers: { 'content-type': 'image/png' }
291
+ })];
292
+ });
293
+ }); })).XHR;
294
+ xhr = new XHR();
295
+ xhr.responseType = 'blob';
296
+ return [4 /*yield*/, run(xhr, 'GET', '/SASjsApi/drive/file?_filePath=%2FPublic%2Fapp%2Fmacrodash%2Fservices%2Fweb%2Fmacrodash.png')];
297
+ case 1:
298
+ _b.sent();
299
+ expect(xhr.status).toBe(200);
300
+ expect(xhr.response).toBeInstanceOf(Blob);
301
+ expect(xhr.response.type).toBe('image/png');
302
+ expect(xhr.response.size).toBe(bytes.length);
303
+ expect(xhr.responseText).toBe('');
304
+ _a = Uint8Array.bind;
305
+ return [4 /*yield*/, xhr.response.arrayBuffer()];
306
+ case 2:
307
+ readBack = new (_a.apply(Uint8Array, [void 0, _b.sent()]))();
308
+ expect(Array.from(readBack)).toEqual(Array.from(bytes));
309
+ return [2 /*return*/];
310
+ }
311
+ });
312
+ }); });
313
+ it('hands an arraybuffer request the bytes as well', function () { return __awaiter(void 0, void 0, void 0, function () {
314
+ var bytes, XHR, xhr;
315
+ return __generator(this, function (_a) {
316
+ switch (_a.label) {
317
+ case 0:
318
+ bytes = new Uint8Array([1, 2, 3, 4]);
319
+ XHR = loadBridge(jest.fn(function () { return __awaiter(void 0, void 0, void 0, function () {
320
+ return __generator(this, function (_a) {
321
+ return [2 /*return*/, new Response(bytes, {
322
+ status: 200,
323
+ headers: { 'content-type': 'application/octet-stream' }
324
+ })];
325
+ });
326
+ }); })).XHR;
327
+ xhr = new XHR();
328
+ xhr.responseType = 'arraybuffer';
329
+ return [4 /*yield*/, run(xhr, 'GET', '/SASjsApi/drive/file?_filePath=%2Fx.bin')];
330
+ case 1:
331
+ _a.sent();
332
+ expect(Array.from(new Uint8Array(xhr.response))).toEqual([1, 2, 3, 4]);
333
+ expect(xhr.responseText).toBe('');
334
+ return [2 /*return*/];
335
+ }
336
+ });
337
+ }); });
278
338
  it('answers with a 500 body when the request itself fails', function () { return __awaiter(void 0, void 0, void 0, function () {
279
339
  var consoleError, XHR, xhr;
280
340
  return __generator(this, function (_a) {
@@ -58,7 +58,12 @@ var loadRuntime = function () {
58
58
  ;
59
59
  global.self = {};
60
60
  jest.resetModules();
61
- return require('../assets/runtime-core.js');
61
+ var runtime = require('../assets/runtime-core.js');
62
+ // the host declares its run times, and the site's worker does the same from its own
63
+ // `RUN_TIMES` (sw.js): an extension-less program is resolved in that order, so a runtime
64
+ // driven without a declaration resolves exact paths only
65
+ runtime.setRunTimes(['js', 'sas']);
66
+ return runtime;
62
67
  };
63
68
  var jsonResponse = function (body) { return ({
64
69
  ok: true,