@sasjs/cli 6.27.1 → 6.27.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/commands/build/internal/mockSite/assets/sw.js +98 -28
- package/build/commands/build/internal/mockSite/mockSite.js +81 -4
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +226 -8
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +378 -112
- package/package.json +3 -3
|
@@ -457,7 +457,48 @@ const transcriptFor = async (programPath) => {
|
|
|
457
457
|
return undefined
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
/**
|
|
461
|
+
* The program a Studio code run came from, from the line the interface puts in
|
|
462
|
+
* front of the buffer.
|
|
463
|
+
*
|
|
464
|
+
* Run in Studio posts the buffer to `/SASjsApi/code/execute` - not the file's
|
|
465
|
+
* path - and the interface prepends `%let _program = <path>;` (its
|
|
466
|
+
* `programPathInjection`) so a program can still see where it lives. That line is
|
|
467
|
+
* the only thing in such a request that names the file, and it is what lets the
|
|
468
|
+
* recording answer a Press Run on the program it recorded.
|
|
469
|
+
*
|
|
470
|
+
* The line is written whatever was sent - the whole buffer or a selection of it -
|
|
471
|
+
* so the recording answers the program its path names, and a visitor who edits
|
|
472
|
+
* the code before pressing Run still sees the session recorded for that file. A
|
|
473
|
+
* browser cannot run SAS at all, so the alternative is a refusal for every SAS
|
|
474
|
+
* run; the demo's program says what it is, and its comment says where the answer
|
|
475
|
+
* comes from.
|
|
476
|
+
*/
|
|
477
|
+
const recordedProgramIn = (code, runTime) => {
|
|
478
|
+
if (String(runTime || '').toLowerCase() !== 'sas') return undefined
|
|
479
|
+
|
|
480
|
+
// anchored: the interface writes the line FIRST (`%let _program = ${path};\n`
|
|
481
|
+
// + the buffer), so a `%let _program` further down is the program's own code -
|
|
482
|
+
// a stored program that sets it deliberately, or a datalines row that looks
|
|
483
|
+
// like it - and is not what this host answers. Without the anchor, a caller's
|
|
484
|
+
// own SAS program could name the recorded file and be handed its session.
|
|
485
|
+
const match = /^\s*%let\s+_program\s*=\s*([^;]+);/i.exec(String(code || ''))
|
|
486
|
+
|
|
487
|
+
return match ? match[1].trim() : undefined
|
|
488
|
+
}
|
|
489
|
+
|
|
460
490
|
// ---------------------------------------------------------------- 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
|
+
|
|
461
502
|
/**
|
|
462
503
|
* The work a run does, over the browser's drive.
|
|
463
504
|
*
|
|
@@ -477,17 +518,19 @@ class BrowserRuntime extends API.MemorySessionHost {
|
|
|
477
518
|
const program = request.program || {}
|
|
478
519
|
const params = Object.assign({}, request.vars || {})
|
|
479
520
|
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
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
|
+
const resolved =
|
|
528
|
+
program.code === undefined ? R.resolveProgram(program.path) : undefined
|
|
529
|
+
const isSas =
|
|
530
|
+
String(request.runTime || '').toLowerCase() === 'sas' ||
|
|
531
|
+
Boolean(resolved && /\.sas$/i.test(resolved))
|
|
487
532
|
|
|
488
|
-
|
|
489
|
-
throw runFailure('"runTime" must be one of [js]')
|
|
490
|
-
}
|
|
533
|
+
if (isSas) throw runFailure(SAS_UNAVAILABLE)
|
|
491
534
|
|
|
492
535
|
const payload = { params, tables: tablesOf(request.files) }
|
|
493
536
|
if (program.code !== undefined) payload.code = program.code
|
|
@@ -580,6 +623,23 @@ const withoutBase = (pathname) =>
|
|
|
580
623
|
? pathname.slice(BASE.length) || '/'
|
|
581
624
|
: pathname
|
|
582
625
|
|
|
626
|
+
// ------------------------------------------------------------- what it can run
|
|
627
|
+
/**
|
|
628
|
+
* The run times this host answers, in the order a client should try them.
|
|
629
|
+
*
|
|
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.
|
|
634
|
+
*
|
|
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.
|
|
640
|
+
*/
|
|
641
|
+
const RUN_TIMES = ['js', 'sas']
|
|
642
|
+
|
|
583
643
|
// ------------------------------------------------------------- host reports
|
|
584
644
|
// The Admin routes ask a host what it is doing. A browser has no process, but it
|
|
585
645
|
// does have a page, a heap and this worker's own activity, so the reports are
|
|
@@ -626,7 +686,7 @@ const adminReports = {
|
|
|
626
686
|
cpuCount: navigator.hardwareConcurrency || 1,
|
|
627
687
|
cpuModel: 'unknown',
|
|
628
688
|
loadAverage: [],
|
|
629
|
-
runTimes:
|
|
689
|
+
runTimes: RUN_TIMES,
|
|
630
690
|
sasjsRoot: '/'
|
|
631
691
|
},
|
|
632
692
|
memory: {
|
|
@@ -772,16 +832,21 @@ const homeService = API.createHomeService(async () => {
|
|
|
772
832
|
|
|
773
833
|
const SERVICES = {
|
|
774
834
|
drive: API.createDriveService(DRIVE_STORE),
|
|
775
|
-
|
|
835
|
+
// The package's browser fixture states one run time; this host serves two, and
|
|
836
|
+
// `info` is what the Studio reads to decide whether a file may be run at all,
|
|
837
|
+
// so the answer is the host's own list rather than the fixture's.
|
|
838
|
+
info: API.createInfoService(
|
|
839
|
+
Object.assign({}, API.BROWSER_INFO, { runTimes: RUN_TIMES })
|
|
840
|
+
),
|
|
776
841
|
identity: IDENTITY,
|
|
777
842
|
records: RECORDS,
|
|
778
843
|
runtime: API.createRuntimeService(IDENTITY_STORE, RUNTIME, {
|
|
779
|
-
// `js` is executed here; `sas` is offered
|
|
780
|
-
//
|
|
781
|
-
// program with no recording is refused in the
|
|
782
|
-
// not be handed to the JavaScript runtime as
|
|
783
|
-
//
|
|
784
|
-
runTimes:
|
|
844
|
+
// `js` is executed here; `sas` is offered because the recording answers the
|
|
845
|
+
// program it names, and the Studio offers Run on a `.sas` file only for a run
|
|
846
|
+
// time the host states. A `.sas` program with no recording is refused in the
|
|
847
|
+
// runner, deliberately - it must not be handed to the JavaScript runtime as
|
|
848
|
+
// source, which is what the guard in BrowserRuntime.run prevents.
|
|
849
|
+
runTimes: RUN_TIMES
|
|
785
850
|
}),
|
|
786
851
|
session: API.createSessionService(IDENTITY_STORE, RUNTIME),
|
|
787
852
|
authConfig: API.createAuthConfigService({}),
|
|
@@ -1146,15 +1211,17 @@ const runRoute = async (routeId, payload, subject) => {
|
|
|
1146
1211
|
runRequest.program = query._program || body._program
|
|
1147
1212
|
}
|
|
1148
1213
|
|
|
1149
|
-
// A recorded SAS session is answered here, ahead of the run
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
|
1158
1225
|
|
|
1159
1226
|
if (recording)
|
|
1160
1227
|
return artifactResponse(
|
|
@@ -1168,7 +1235,10 @@ const runRoute = async (routeId, payload, subject) => {
|
|
|
1168
1235
|
// the same rule runtime-core applies to a service that declares none
|
|
1169
1236
|
headers: SEEDED_HEADER_LINE
|
|
1170
1237
|
},
|
|
1171
|
-
|
|
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
|
|
1172
1242
|
)
|
|
1173
1243
|
)
|
|
1174
1244
|
}
|
|
@@ -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
|
/**
|
|
@@ -57,6 +57,31 @@ var __values = (this && this.__values) || function(o) {
|
|
|
57
57
|
};
|
|
58
58
|
throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
|
|
59
59
|
};
|
|
60
|
+
var __read = (this && this.__read) || function (o, n) {
|
|
61
|
+
var m = typeof Symbol === "function" && o[Symbol.iterator];
|
|
62
|
+
if (!m) return o;
|
|
63
|
+
var i = m.call(o), r, ar = [], e;
|
|
64
|
+
try {
|
|
65
|
+
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
|
|
66
|
+
}
|
|
67
|
+
catch (error) { e = { error: error }; }
|
|
68
|
+
finally {
|
|
69
|
+
try {
|
|
70
|
+
if (r && !r.done && (m = i["return"])) m.call(i);
|
|
71
|
+
}
|
|
72
|
+
finally { if (e) throw e.error; }
|
|
73
|
+
}
|
|
74
|
+
return ar;
|
|
75
|
+
};
|
|
76
|
+
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
|
77
|
+
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
|
|
78
|
+
if (ar || !(i in from)) {
|
|
79
|
+
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
|
|
80
|
+
ar[i] = from[i];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return to.concat(ar || Array.prototype.slice.call(from));
|
|
84
|
+
};
|
|
60
85
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
61
86
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
62
87
|
};
|
|
@@ -81,11 +106,22 @@ var createProject = function (root) {
|
|
|
81
106
|
write(path_1.default.join(build, 'server-ci.sas'), '%put build pack;\n');
|
|
82
107
|
write(path_1.default.join(build, 'server-ci.json'), '{"members":[]}\n');
|
|
83
108
|
write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
|
|
84
|
-
'<!doctype html><html><head><title>App</title
|
|
109
|
+
'<!doctype html><html><head><title>App</title>',
|
|
110
|
+
'<link rel="stylesheet" href="theme-boot.css" />',
|
|
111
|
+
// a paint-critical script: it runs nothing that needs the worker, so the
|
|
112
|
+
// page marks it to stay in the parser rather than being deferred
|
|
113
|
+
'<script src="theme-boot.js" data-early></script>',
|
|
114
|
+
// the same marker on a module cannot be honoured - a module is deferred by
|
|
115
|
+
// specification - so the build must replay it and say why
|
|
116
|
+
'<script src="deferred.js" type="module" data-early></script>',
|
|
117
|
+
'</head><body>',
|
|
85
118
|
"<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\" servertype=\"SASJS\" loginmechanism=\"Redirected\"></sasjs>"),
|
|
86
119
|
'<script src="main.abc123.js" type="module"></script>',
|
|
87
120
|
'</body></html>'
|
|
88
121
|
].join('\n'));
|
|
122
|
+
write(path_1.default.join(root, 'client', 'dist', 'theme-boot.css'), 'html { background-color: #121212 }\n');
|
|
123
|
+
write(path_1.default.join(root, 'client', 'dist', 'deferred.js'), 'console.log("deferred")\n');
|
|
124
|
+
write(path_1.default.join(root, 'client', 'dist', 'theme-boot.js'), 'document.documentElement.style.backgroundColor = "#121212"\n');
|
|
89
125
|
write(path_1.default.join(root, 'client', 'dist', 'main.abc123.js'), 'console.log(1)\n');
|
|
90
126
|
};
|
|
91
127
|
describe('browser-hosted build', function () {
|
|
@@ -467,6 +503,182 @@ describe('browser-hosted build', function () {
|
|
|
467
503
|
}
|
|
468
504
|
});
|
|
469
505
|
}); });
|
|
506
|
+
/**
|
|
507
|
+
* A page's paint-critical script must run before the worker exists.
|
|
508
|
+
*
|
|
509
|
+
* The loader defers every script so the worker is ready before the app asks it
|
|
510
|
+
* for anything - right for the app, wrong for a script whose job is the canvas:
|
|
511
|
+
* the page stays unpainted (white) for the seconds the worker takes to claim
|
|
512
|
+
* it, which is the white flash a dark interface shows on every load. A tag the
|
|
513
|
+
* page marks `data-early` says it needs no worker, so the parser keeps it.
|
|
514
|
+
*/
|
|
515
|
+
it('leaves a data-early script to the parser, in the page', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
516
|
+
var html, specs;
|
|
517
|
+
return __generator(this, function (_a) {
|
|
518
|
+
switch (_a.label) {
|
|
519
|
+
case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
520
|
+
case 1:
|
|
521
|
+
_a.sent();
|
|
522
|
+
html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
|
|
523
|
+
specs = JSON.parse(html
|
|
524
|
+
.match(/data-scripts='([^']*)'/)[1]
|
|
525
|
+
.replace(/'/g, "'")
|
|
526
|
+
.replace(/&/g, '&'));
|
|
527
|
+
// still in the page, before the loader, and not replayed through it
|
|
528
|
+
expect(html).toMatch(/<script src="theme-boot\.js\?v=[0-9a-f]{10}" data-early><\/script>/);
|
|
529
|
+
expect(specs.map(function (spec) { return spec.src; })).not.toContain(expect.stringMatching(/theme-boot/));
|
|
530
|
+
expect(html.indexOf('theme-boot.js')).toBeLessThan(html.indexOf('host/boot.js'));
|
|
531
|
+
// the same marker on a module cannot be honoured: a module is deferred by
|
|
532
|
+
// specification, so it is replayed and the author is told
|
|
533
|
+
expect(html).not.toContain('<script src="deferred.js');
|
|
534
|
+
expect(specs.map(function (spec) { return spec.src; })).toContainEqual(expect.stringMatching(/^deferred\.js\?v=/));
|
|
535
|
+
expect(process.logger.warn).toHaveBeenCalledWith(expect.stringContaining('a data-early script is a module'));
|
|
536
|
+
return [2 /*return*/];
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}); });
|
|
540
|
+
it('gives a linked stylesheet a URL that follows its contents', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
541
|
+
var html;
|
|
542
|
+
return __generator(this, function (_a) {
|
|
543
|
+
switch (_a.label) {
|
|
544
|
+
case 0:
|
|
545
|
+
// the host caches an asset for two hours: a palette change behind an
|
|
546
|
+
// unchanged URL reaches a returning reader late, which for the canvas
|
|
547
|
+
// stylesheet is the white flash the fix exists to remove
|
|
548
|
+
return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
549
|
+
case 1:
|
|
550
|
+
// the host caches an asset for two hours: a palette change behind an
|
|
551
|
+
// unchanged URL reaches a returning reader late, which for the canvas
|
|
552
|
+
// stylesheet is the white flash the fix exists to remove
|
|
553
|
+
_a.sent();
|
|
554
|
+
html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
|
|
555
|
+
expect(html).toMatch(/<link rel="stylesheet" href="theme-boot\.css\?v=[0-9a-f]{10}" \/>/);
|
|
556
|
+
return [2 /*return*/];
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}); });
|
|
560
|
+
it("leaves a stylesheet alone when the file is not this build's", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
561
|
+
var html, specs;
|
|
562
|
+
return __generator(this, function (_a) {
|
|
563
|
+
switch (_a.label) {
|
|
564
|
+
case 0:
|
|
565
|
+
// a file this build does not own: guessing a version for it would break it.
|
|
566
|
+
// A URL that pins an `integrity` hash is versioned like any other - SRI
|
|
567
|
+
// digests the fetched bytes, so a `?v=` URL still satisfies the hash.
|
|
568
|
+
write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
|
|
569
|
+
'<!doctype html><html><head><title>App</title>',
|
|
570
|
+
'<link rel="stylesheet" href="https://cdn.example.com/lib.css" />',
|
|
571
|
+
'<link rel="stylesheet" href="theme-boot.css" integrity="sha384-abc" crossorigin="anonymous" />',
|
|
572
|
+
'</head><body>',
|
|
573
|
+
"<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\"></sasjs>"),
|
|
574
|
+
'<script src="main.abc123.js" type="module" integrity="sha384-def"></script>',
|
|
575
|
+
'</body></html>'
|
|
576
|
+
].join('\n'));
|
|
577
|
+
return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
578
|
+
case 1:
|
|
579
|
+
_a.sent();
|
|
580
|
+
html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
|
|
581
|
+
specs = JSON.parse(html
|
|
582
|
+
.match(/data-scripts='([^']*)'/)[1]
|
|
583
|
+
.replace(/'/g, "'")
|
|
584
|
+
.replace(/&/g, '&'));
|
|
585
|
+
// not owned by this build: untouched
|
|
586
|
+
expect(html).toContain('href="https://cdn.example.com/lib.css"');
|
|
587
|
+
// owned, hashed: versioned, hash carried through
|
|
588
|
+
expect(html).toMatch(/href="theme-boot\.css\?v=[0-9a-f]{10}" integrity="sha384-abc" crossorigin="anonymous"/);
|
|
589
|
+
expect(specs[0].src).toMatch(/^main\.abc123\.js\?v=[0-9a-f]{10}$/);
|
|
590
|
+
expect(specs[0].integrity).toBe('sha384-def');
|
|
591
|
+
return [2 /*return*/];
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}); });
|
|
595
|
+
it('does not let a $ in a file name rewrite the page', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
596
|
+
var html;
|
|
597
|
+
return __generator(this, function (_a) {
|
|
598
|
+
switch (_a.label) {
|
|
599
|
+
case 0:
|
|
600
|
+
// `String.prototype.replace` interprets `$&`, `$1` and the rest in a
|
|
601
|
+
// replacement STRING, and these tags carry the page's own file names - so a
|
|
602
|
+
// `$&` in one would splice the matched markup into the src
|
|
603
|
+
write(path_1.default.join(root, 'client', 'dist', '$&x.js'), 'console.log("dollar")\n');
|
|
604
|
+
write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
|
|
605
|
+
'<!doctype html><html><head><title>App</title>',
|
|
606
|
+
'<script src="$&x.js" data-early></script>',
|
|
607
|
+
'</head><body>',
|
|
608
|
+
"<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\"></sasjs>"),
|
|
609
|
+
'<script src="main.abc123.js"></script>',
|
|
610
|
+
'</body></html>'
|
|
611
|
+
].join('\n'));
|
|
612
|
+
return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
613
|
+
case 1:
|
|
614
|
+
_a.sent();
|
|
615
|
+
html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
|
|
616
|
+
expect(html).toContain("<script src=\"$&x.js?v=".concat(require('crypto')
|
|
617
|
+
.createHash('md5')
|
|
618
|
+
.update(fs_1.default.readFileSync(path_1.default.join(root, 'client', 'dist', '$&x.js')))
|
|
619
|
+
.digest('hex')
|
|
620
|
+
.slice(0, 10), "\" data-early></script>"));
|
|
621
|
+
// one script tag, not a nested one spliced in by the expansion
|
|
622
|
+
expect(html.split('<script').length - 1).toBe(2); // the early tag + the loader
|
|
623
|
+
return [2 /*return*/];
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
}); });
|
|
627
|
+
it('versions a preload of the same stylesheet', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
628
|
+
var html, urls;
|
|
629
|
+
return __generator(this, function (_a) {
|
|
630
|
+
switch (_a.label) {
|
|
631
|
+
case 0:
|
|
632
|
+
// a preload left at the unversioned URL warms one cache entry while the
|
|
633
|
+
// stylesheet is fetched cold at another, which is the request it existed to save
|
|
634
|
+
write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
|
|
635
|
+
'<!doctype html><html><head><title>App</title>',
|
|
636
|
+
'<link rel="preload" as="style" href="theme-boot.css" />',
|
|
637
|
+
'<link rel="stylesheet" href="theme-boot.css" />',
|
|
638
|
+
'</head><body>',
|
|
639
|
+
"<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\"></sasjs>"),
|
|
640
|
+
'<script src="main.abc123.js"></script>',
|
|
641
|
+
'</body></html>'
|
|
642
|
+
].join('\n'));
|
|
643
|
+
return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
644
|
+
case 1:
|
|
645
|
+
_a.sent();
|
|
646
|
+
html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
|
|
647
|
+
urls = __spreadArray([], __read(html.matchAll(/theme-boot\.css\?v=([0-9a-f]{10})/g)), false).map(function (match) { return match[1]; });
|
|
648
|
+
expect(urls).toHaveLength(2);
|
|
649
|
+
expect(urls[0]).toBe(urls[1]);
|
|
650
|
+
return [2 /*return*/];
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
}); });
|
|
654
|
+
it('installs the loader for a page whose only script runs early', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
655
|
+
var html, specs;
|
|
656
|
+
return __generator(this, function (_a) {
|
|
657
|
+
switch (_a.label) {
|
|
658
|
+
case 0:
|
|
659
|
+
// the loader is what installs the worker, the bridge and the drive: a page
|
|
660
|
+
// whose one script is kept in the parser still needs it, and must not be told
|
|
661
|
+
// it has no scripts
|
|
662
|
+
write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
|
|
663
|
+
'<!doctype html><html><head><title>App</title>',
|
|
664
|
+
'<script src="theme-boot.js" data-early></script>',
|
|
665
|
+
'</head><body>',
|
|
666
|
+
"<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\"></sasjs>"),
|
|
667
|
+
'</body></html>'
|
|
668
|
+
].join('\n'));
|
|
669
|
+
return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
670
|
+
case 1:
|
|
671
|
+
_a.sent();
|
|
672
|
+
html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
|
|
673
|
+
specs = html.match(/data-scripts='([^']*)'/)[1];
|
|
674
|
+
expect(html).toContain('<script src="host/boot.js?v=');
|
|
675
|
+
expect(html).toContain('data-early');
|
|
676
|
+
expect(specs).toBe('[]');
|
|
677
|
+
expect(process.logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('no script tags found'));
|
|
678
|
+
return [2 /*return*/];
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
}); });
|
|
470
682
|
it('points the app at itself and installs the boot loader', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
471
683
|
var html, specs;
|
|
472
684
|
return __generator(this, function (_a) {
|
|
@@ -490,9 +702,12 @@ describe('browser-hosted build', function () {
|
|
|
490
702
|
.match(/data-scripts='([^']*)'/)[1]
|
|
491
703
|
.replace(/'/g, "'")
|
|
492
704
|
.replace(/&/g, '&'));
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
expect(specs
|
|
705
|
+
// two: the module the page marked data-early (replayed, with a warning),
|
|
706
|
+
// then the bundle - both in the order the page had them
|
|
707
|
+
expect(specs).toHaveLength(2);
|
|
708
|
+
expect(specs[0].src).toMatch(/^deferred\.js\?v=[0-9a-f]{10}$/);
|
|
709
|
+
expect(specs[1].type).toBe('module');
|
|
710
|
+
expect(specs[1].src).toMatch(/^main\.abc123\.js\?v=[0-9a-f]{10}$/);
|
|
496
711
|
return [2 /*return*/];
|
|
497
712
|
}
|
|
498
713
|
});
|
|
@@ -503,7 +718,7 @@ describe('browser-hosted build', function () {
|
|
|
503
718
|
* bundle's name never changes, so the URL is what has to change.
|
|
504
719
|
*/
|
|
505
720
|
it('gives each app script a URL that follows its contents', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
506
|
-
var specs, first;
|
|
721
|
+
var specs, bundleSrc, first;
|
|
507
722
|
return __generator(this, function (_a) {
|
|
508
723
|
switch (_a.label) {
|
|
509
724
|
case 0:
|
|
@@ -512,23 +727,26 @@ describe('browser-hosted build', function () {
|
|
|
512
727
|
var tag = html.match(/data-scripts='([^']*)'/);
|
|
513
728
|
return JSON.parse(tag[1].replace(/'/g, "'").replace(/&/g, '&'));
|
|
514
729
|
};
|
|
730
|
+
bundleSrc = function () {
|
|
731
|
+
return specs().find(function (spec) { var _a; return (_a = spec.src) === null || _a === void 0 ? void 0 : _a.startsWith('main.'); });
|
|
732
|
+
};
|
|
515
733
|
return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
516
734
|
case 1:
|
|
517
735
|
_a.sent();
|
|
518
|
-
first =
|
|
736
|
+
first = bundleSrc().src;
|
|
519
737
|
expect(first).toMatch(/^main\.abc123\.js\?v=[0-9a-f]{10}$/);
|
|
520
738
|
// the same file twice: nothing about the URL changes
|
|
521
739
|
return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
522
740
|
case 2:
|
|
523
741
|
// the same file twice: nothing about the URL changes
|
|
524
742
|
_a.sent();
|
|
525
|
-
expect(
|
|
743
|
+
expect(bundleSrc().src).toBe(first);
|
|
526
744
|
// a different bundle: a different URL, which is the caching bust
|
|
527
745
|
write(path_1.default.join(root, 'client', 'dist', 'main.abc123.js'), 'console.log(2)\n');
|
|
528
746
|
return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
529
747
|
case 3:
|
|
530
748
|
_a.sent();
|
|
531
|
-
expect(
|
|
749
|
+
expect(bundleSrc().src).not.toBe(first);
|
|
532
750
|
// and a script this build does not own is left exactly as the page asked
|
|
533
751
|
write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
|
|
534
752
|
'<!doctype html><html><head><title>App</title></head><body>',
|