@sasjs/cli 6.27.2 → 6.28.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.
- package/build/commands/build/internal/mockSite/assets/intercept.js +40 -4
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +32 -1
- package/build/commands/build/internal/mockSite/assets/sw.js +191 -77
- package/build/commands/build/internal/mockSite/mockSite.js +81 -4
- package/build/commands/build/internal/mockSite/spec/intercept.spec.js +60 -0
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +226 -8
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +6 -1
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +321 -41
- package/package.json +2 -2
|
@@ -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:
|
|
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 [
|
|
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
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
455
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
540
|
-
|
|
649
|
+
const result = R.run(payload)
|
|
650
|
+
if (result.error) throw runFailure(result.error)
|
|
541
651
|
|
|
542
|
-
|
|
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
|
|
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
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
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
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
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
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
// the package would compile the source as JavaScript. A trigger is
|
|
1218
|
-
// it has to answer with the session record the route builds, which a client
|
|
1219
|
-
//
|
|
1220
|
-
if (!trigger) {
|
|
1221
|
-
const
|
|
1222
|
-
? recordedProgramIn(body.code, body.runTime)
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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) {
|
|
@@ -653,6 +653,11 @@ var versionScripts = function (scripts, outputFolder) {
|
|
|
653
653
|
var src = script.src;
|
|
654
654
|
if (!src)
|
|
655
655
|
return script;
|
|
656
|
+
// A `?v=<contents>` URL is safe for an asset that pins a subresource-integrity
|
|
657
|
+
// hash: SRI digests the fetched BYTES, not the URL, so the hash still matches.
|
|
658
|
+
// The version is what stops a stale copy being served at all - a changed file
|
|
659
|
+
// gets a new URL, and an unchanged one is byte-identical to what the hash was
|
|
660
|
+
// computed from.
|
|
656
661
|
var _a = __read(src.split('?'), 2), pathname = _a[0], query = _a[1];
|
|
657
662
|
var file = path_1.default.join(outputFolder, pathname.replace(/^(\.\/|\/)/, ''));
|
|
658
663
|
if (!fs_1.default.existsSync(file))
|
|
@@ -688,6 +693,24 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
|
|
|
688
693
|
warnings.push('no <sasjs> element found - check the adapter configuration manually');
|
|
689
694
|
}
|
|
690
695
|
var scripts = [];
|
|
696
|
+
/**
|
|
697
|
+
* Scripts the page marks `data-early`, which stay in the page and run in the
|
|
698
|
+
* parser instead of through the loader.
|
|
699
|
+
*
|
|
700
|
+
* The loader defers every script so the worker is ready before the app asks it
|
|
701
|
+
* for anything, which is right for the app and wrong for a script that paints:
|
|
702
|
+
* on the demonstration site the canvas stays unpainted (white) for the seconds
|
|
703
|
+
* the worker takes to claim the page, so a dark interface announces itself with
|
|
704
|
+
* a full-screen white flash. A page that marks a script `data-early` is saying
|
|
705
|
+
* it runs nothing that needs the worker - the theme script reads localStorage
|
|
706
|
+
* and sets a background - so the parser keeps it where the page put it.
|
|
707
|
+
*
|
|
708
|
+
* Only a classic script can be kept: a module is deferred by specification
|
|
709
|
+
* however it is placed, and an inline one is refused by the page's own policy.
|
|
710
|
+
* Both are reported and replayed like any other script rather than being
|
|
711
|
+
* promised something the browser will not do.
|
|
712
|
+
*/
|
|
713
|
+
var early = [];
|
|
691
714
|
var tagPattern = /<script\b([^>]*)>([\s\S]*?)<\/script>[ \t]*\r?\n?/gi;
|
|
692
715
|
patched = patched.replace(tagPattern, function (tag, source, body) {
|
|
693
716
|
var e_7, _a;
|
|
@@ -697,6 +720,16 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
|
|
|
697
720
|
// data blocks stay in the page; they are not scripts to replay
|
|
698
721
|
if (!EXECUTABLE_TYPES.includes(type))
|
|
699
722
|
return tag;
|
|
723
|
+
if (attributes['data-early'] !== undefined) {
|
|
724
|
+
if (inlined)
|
|
725
|
+
warnings.push('a data-early script carries its code inline - only a file can stay in the page, because the page is served under a policy that refuses inline script');
|
|
726
|
+
else if (type === 'module')
|
|
727
|
+
warnings.push('a data-early script is a module - a module is deferred by specification, so it cannot run before the loader; it is replayed like any other script');
|
|
728
|
+
else {
|
|
729
|
+
early.push({ src: attributes.src, tag: tag });
|
|
730
|
+
return tag;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
700
733
|
var spec = inlined
|
|
701
734
|
? {}
|
|
702
735
|
: { src: attributes.src };
|
|
@@ -729,18 +762,62 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
|
|
|
729
762
|
scripts.push(spec);
|
|
730
763
|
return '';
|
|
731
764
|
});
|
|
732
|
-
if (scripts.length) {
|
|
765
|
+
if (scripts.length || early.length) {
|
|
733
766
|
var versioned = outputFolder
|
|
734
767
|
? versionScripts(scripts, outputFolder)
|
|
735
768
|
: scripts;
|
|
736
|
-
var
|
|
769
|
+
var tag_1 = bootTag(versioned, version, basePath);
|
|
770
|
+
// a function, not a replacement string: the tag carries the page's own srcs
|
|
771
|
+
// in `data-scripts`, and a `$&` in one would expand to the matched markup
|
|
737
772
|
patched = /<\/body>/i.test(patched)
|
|
738
|
-
? patched.replace(/<\/body>/i, "".concat(
|
|
739
|
-
: patched +
|
|
773
|
+
? patched.replace(/<\/body>/i, function () { return "".concat(tag_1, "\n</body>"); })
|
|
774
|
+
: patched + tag_1;
|
|
740
775
|
}
|
|
741
776
|
else {
|
|
742
777
|
warnings.push('no script tags found - the mock loader was not installed');
|
|
743
778
|
}
|
|
779
|
+
// The early scripts keep their place in the page, and their URL follows the
|
|
780
|
+
// file's contents for the same reason the replayed ones do: the host caches an
|
|
781
|
+
// asset for two hours and a theme script that never changes its URL is a theme
|
|
782
|
+
// script a returning reader keeps from the previous build.
|
|
783
|
+
if (early.length && outputFolder) {
|
|
784
|
+
var versioned_1 = versionScripts(early.map(function (entry) { return ({ src: entry.src }); }), outputFolder);
|
|
785
|
+
early.forEach(function (entry, index) {
|
|
786
|
+
// Function replacements throughout: a replacement STRING interprets `$&`,
|
|
787
|
+
// `$1` and the rest of the replacement patterns, and both the tag and the
|
|
788
|
+
// src are the page's own text - a `$&` in a file name would splice the
|
|
789
|
+
// surrounding markup into the src.
|
|
790
|
+
var withVersion = entry.tag.replace(entry.src, function () { return versioned_1[index].src; });
|
|
791
|
+
patched = patched.replace(entry.tag, function () { return withVersion; });
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
// A stylesheet the page links is a page asset like a script, and the one that
|
|
795
|
+
// paints the canvas is the reason this matters: a palette change behind an
|
|
796
|
+
// unchanged URL reaches a returning reader up to two hours late, which is a
|
|
797
|
+
// white flash the fix was meant to remove. Its URL follows the file's contents
|
|
798
|
+
// for the same reason a script's does. An `integrity` attribute travels with
|
|
799
|
+
// it untouched - SRI digests the bytes, so a versioned URL still satisfies it.
|
|
800
|
+
//
|
|
801
|
+
// A `rel=preload as=style` for the same file is versioned too: leaving it at
|
|
802
|
+
// the unversioned URL warms one cache entry while the stylesheet is fetched
|
|
803
|
+
// cold at another, which is the request the preload existed to save.
|
|
804
|
+
if (outputFolder) {
|
|
805
|
+
patched = patched.replace(/<link\b([^>]*)>/gi, function (tag, source) {
|
|
806
|
+
var attributes = parseAttributes(source);
|
|
807
|
+
var rel = (attributes.rel || '').trim().toLowerCase().split(/\s+/);
|
|
808
|
+
var preloadsStyle = rel.includes('preload') &&
|
|
809
|
+
(attributes.as || '').trim().toLowerCase() === 'style';
|
|
810
|
+
if (!rel.includes('stylesheet') && !preloadsStyle)
|
|
811
|
+
return tag;
|
|
812
|
+
var href = attributes.href;
|
|
813
|
+
if (!href)
|
|
814
|
+
return tag;
|
|
815
|
+
var _a = __read(versionScripts([{ src: href }], outputFolder), 1), versioned = _a[0];
|
|
816
|
+
return versioned.src === href
|
|
817
|
+
? tag
|
|
818
|
+
: tag.replace(href, function () { return versioned.src; });
|
|
819
|
+
});
|
|
820
|
+
}
|
|
744
821
|
return { patched: patched, warnings: warnings };
|
|
745
822
|
};
|
|
746
823
|
/**
|
|
@@ -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) {
|