@sasjs/cli 6.17.0 → 6.19.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/boot.js +8 -1
- package/build/commands/build/internal/mockSite/assets/intercept.js +31 -2
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +10 -5
- package/build/commands/build/internal/mockSite/assets/sw.js +37 -12
- package/build/commands/build/internal/mockSite/mockSite.js +53 -25
- package/build/commands/build/internal/mockSite/spec/boot.spec.js +35 -8
- package/build/commands/build/internal/mockSite/spec/intercept.spec.js +26 -0
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +53 -17
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +96 -2
- package/package.json +1 -1
|
@@ -114,8 +114,15 @@
|
|
|
114
114
|
stamp = ''
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// Registered relative to the page, and scoped to the directory it sits in:
|
|
118
|
+
// the site is served from the origin root on one host and from a folder on
|
|
119
|
+
// another (a demonstration site beside its own docs), and the worker's scope
|
|
120
|
+
// has to follow the page rather than assume the root.
|
|
117
121
|
navigator.serviceWorker
|
|
118
|
-
.register('
|
|
122
|
+
.register(new URL('sw.js' + stamp, location.href).href, {
|
|
123
|
+
scope: './',
|
|
124
|
+
updateViaCache: 'none'
|
|
125
|
+
})
|
|
119
126
|
.then(function () {
|
|
120
127
|
return navigator.serviceWorker.ready
|
|
121
128
|
})
|
|
@@ -31,6 +31,31 @@
|
|
|
31
31
|
if (DEBUG) console.log('[sasjs-mock]', ...args)
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A file's contents, as text or as base64 with a flag.
|
|
36
|
+
*
|
|
37
|
+
* An upload is a file, not a string: a PNG, a workbook or an .sas7bdat read
|
|
38
|
+
* with `text()` arrives as replacement characters, and the drive would store
|
|
39
|
+
* the damage under a success status. The bytes are decoded as UTF-8 strictly -
|
|
40
|
+
* if that fails, they are not text by any reading a browser would accept - and
|
|
41
|
+
* carried as base64, which the worker turns back into the bytes the drive
|
|
42
|
+
* keeps.
|
|
43
|
+
*/
|
|
44
|
+
const contentsOf = async (value) => {
|
|
45
|
+
const bytes = new Uint8Array(await value.arrayBuffer())
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return { csv: new TextDecoder('utf-8', { fatal: true }).decode(bytes) }
|
|
49
|
+
} catch (error) {
|
|
50
|
+
let binary = ''
|
|
51
|
+
const chunk = 0x8000
|
|
52
|
+
for (let i = 0; i < bytes.length; i += chunk)
|
|
53
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk))
|
|
54
|
+
|
|
55
|
+
return { csv: btoa(binary), binary: true }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
const readBody = async (body) => {
|
|
35
60
|
const params = {}
|
|
36
61
|
const tables = []
|
|
@@ -39,12 +64,16 @@
|
|
|
39
64
|
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
40
65
|
for (const [key, value] of body.entries()) {
|
|
41
66
|
if (typeof value === 'string') params[key] = value
|
|
42
|
-
else
|
|
67
|
+
else {
|
|
68
|
+
const contents = await contentsOf(value)
|
|
69
|
+
|
|
43
70
|
tables.push({
|
|
44
71
|
name: key,
|
|
45
72
|
filename: value.name || key + '.csv',
|
|
46
|
-
csv:
|
|
73
|
+
csv: contents.csv,
|
|
74
|
+
...(contents.binary ? { binary: true } : {})
|
|
47
75
|
})
|
|
76
|
+
}
|
|
48
77
|
}
|
|
49
78
|
return { params, tables }
|
|
50
79
|
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* encoding returns a string for either, as node does.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
if (typeof importScripts === 'function') importScripts('
|
|
35
|
+
if (typeof importScripts === 'function') importScripts('host/md5.js')
|
|
36
36
|
|
|
37
37
|
// ---------------------------------------------------------------- posix path
|
|
38
38
|
const P = {
|
|
@@ -841,8 +841,8 @@ const DRIVE_ROOT = '/sasjs/drive'
|
|
|
841
841
|
// Rewritten at boot from host/mock-config.json, which the mock build writes.
|
|
842
842
|
let APP_LOC = '/Public/app'
|
|
843
843
|
let DRIVE_APP = `${DRIVE_ROOT}/files${APP_LOC}`
|
|
844
|
-
|
|
845
|
-
const SNAPSHOT_PATH = '
|
|
844
|
+
let SEED_PATH = 'host/vfs-seed.json'
|
|
845
|
+
const SNAPSHOT_PATH = 'host/vfs-snapshot.json'
|
|
846
846
|
|
|
847
847
|
/**
|
|
848
848
|
* The drive storage format, stamped into the seed and the snapshot. The markers
|
|
@@ -864,13 +864,18 @@ const DRIVE_FORMAT = 1
|
|
|
864
864
|
*/
|
|
865
865
|
const loadMockConfig = async () => {
|
|
866
866
|
try {
|
|
867
|
-
const res = await fetch('
|
|
867
|
+
const res = await fetch('host/mock-config.json', { cache: 'no-store' })
|
|
868
868
|
if (!res.ok) return
|
|
869
869
|
const cfg = await res.json()
|
|
870
870
|
if (cfg && typeof cfg.appLoc === 'string' && cfg.appLoc.trim()) {
|
|
871
871
|
APP_LOC = '/' + cfg.appLoc.replace(/^\/+/, '').replace(/\/+$/, '')
|
|
872
872
|
DRIVE_APP = `${DRIVE_ROOT}/files${APP_LOC}`
|
|
873
873
|
}
|
|
874
|
+
// the build names the seed it wrote; the default above covers a site built
|
|
875
|
+
// before the config carried it
|
|
876
|
+
if (cfg && typeof cfg.seedPath === 'string' && cfg.seedPath.trim()) {
|
|
877
|
+
SEED_PATH = cfg.seedPath
|
|
878
|
+
}
|
|
874
879
|
} catch (e) {
|
|
875
880
|
/* keep the defaults */
|
|
876
881
|
}
|
|
@@ -887,7 +892,7 @@ let weboutSentinels = null
|
|
|
887
892
|
|
|
888
893
|
const loadWeboutPrelude = async () => {
|
|
889
894
|
try {
|
|
890
|
-
const res = await fetch('
|
|
895
|
+
const res = await fetch('host/webout-prelude.json', { cache: 'no-store' })
|
|
891
896
|
if (!res.ok) return
|
|
892
897
|
const cfg = await res.json()
|
|
893
898
|
if (cfg && typeof cfg.template === 'string') {
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
* services are the package's; this file supplies the transport, the worker's own
|
|
34
34
|
* activity, and the browser's answers to questions a server asks its process.
|
|
35
35
|
*/
|
|
36
|
-
importScripts('
|
|
37
|
-
importScripts('
|
|
38
|
-
importScripts('
|
|
36
|
+
importScripts('host/runtime-core.js')
|
|
37
|
+
importScripts('host/api-browser.js')
|
|
38
|
+
importScripts('host/lint-browser.js')
|
|
39
39
|
|
|
40
40
|
const R = self.SASjsMockRuntime
|
|
41
41
|
|
|
@@ -317,6 +317,21 @@ const tablesOf = (files) => {
|
|
|
317
317
|
|
|
318
318
|
const RUNTIME = new BrowserRuntime()
|
|
319
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The folder this site is served from.
|
|
322
|
+
*
|
|
323
|
+
* Empty at the origin root, `/demo` where a host publishes the demonstration
|
|
324
|
+
* site beside its own docs. The worker sits at `<base>/sw.js`, so its own
|
|
325
|
+
* location names the base and the route table can keep matching the paths the
|
|
326
|
+
* document states.
|
|
327
|
+
*/
|
|
328
|
+
const BASE = (self.location.pathname || '').replace(/\/sw\.js.*$/, '')
|
|
329
|
+
|
|
330
|
+
const withoutBase = (pathname) =>
|
|
331
|
+
BASE && pathname.startsWith(BASE)
|
|
332
|
+
? pathname.slice(BASE.length) || '/'
|
|
333
|
+
: pathname
|
|
334
|
+
|
|
320
335
|
// ------------------------------------------------------------- host reports
|
|
321
336
|
// The Admin routes ask a host what it is doing. A browser has no process, but it
|
|
322
337
|
// does have a page, a heap and this worker's own activity, so the reports are
|
|
@@ -448,7 +463,7 @@ const registry = () => {
|
|
|
448
463
|
registryPromise = (async () => {
|
|
449
464
|
let appLoc
|
|
450
465
|
try {
|
|
451
|
-
const config = await (await fetch('
|
|
466
|
+
const config = await (await fetch('host/mock-config.json')).json()
|
|
452
467
|
appLoc = config && config.appLoc
|
|
453
468
|
} catch (e) {
|
|
454
469
|
appLoc = undefined
|
|
@@ -477,7 +492,7 @@ const contentVersion = (text) => {
|
|
|
477
492
|
|
|
478
493
|
const homeService = API.createHomeService(async () => {
|
|
479
494
|
try {
|
|
480
|
-
const response = await fetch('
|
|
495
|
+
const response = await fetch('index.html', { cache: 'no-store' })
|
|
481
496
|
if (!response.ok) return undefined
|
|
482
497
|
const html = await response.text()
|
|
483
498
|
return { html, version: contentVersion(html) }
|
|
@@ -574,7 +589,15 @@ const readJsonBody = async (request) => {
|
|
|
574
589
|
name: table.name || `table${index + 1}`,
|
|
575
590
|
filename:
|
|
576
591
|
table.filename || `${table.name || 'table' + (index + 1)}.csv`,
|
|
577
|
-
|
|
592
|
+
// A file the bridge could not read as UTF-8 travels as base64 with a
|
|
593
|
+
// flag: writing its text would replace the bytes and store the damage
|
|
594
|
+
// under a success status, which is what a binary upload used to do.
|
|
595
|
+
contents:
|
|
596
|
+
table.csv === undefined
|
|
597
|
+
? ''
|
|
598
|
+
: table.binary
|
|
599
|
+
? toBytes(table.csv)
|
|
600
|
+
: table.csv
|
|
578
601
|
}))
|
|
579
602
|
}
|
|
580
603
|
}
|
|
@@ -852,7 +875,7 @@ const contentsResponse = (answer, path) => {
|
|
|
852
875
|
}
|
|
853
876
|
|
|
854
877
|
const routeRequest = async (request, url) => {
|
|
855
|
-
const path = url.pathname.replace(/\/+$/, '') || '/'
|
|
878
|
+
const path = withoutBase(url.pathname).replace(/\/+$/, '') || '/'
|
|
856
879
|
const query = {}
|
|
857
880
|
for (const [k, v] of url.searchParams.entries()) query[k] = v
|
|
858
881
|
|
|
@@ -873,13 +896,14 @@ const routeRequest = async (request, url) => {
|
|
|
873
896
|
// serve nothing at `/AppStream/<name>/` here, and a listing that named it
|
|
874
897
|
// would offer a tile that 404s.
|
|
875
898
|
// `path` has its trailing slashes stripped, and these four answers differ by
|
|
876
|
-
// exactly that, so they are decided on the request's own pathname
|
|
877
|
-
|
|
899
|
+
// exactly that, so they are decided on the request's own pathname - minus
|
|
900
|
+
// the folder the site is served from, which the four patterns do not name.
|
|
901
|
+
const stream = withoutBase(url.pathname)
|
|
878
902
|
|
|
879
903
|
if (/^\/AppStream$/i.test(stream))
|
|
880
904
|
return new Response(null, {
|
|
881
905
|
status: 301,
|
|
882
|
-
headers: { location:
|
|
906
|
+
headers: { location: `${BASE}/AppStream/` }
|
|
883
907
|
})
|
|
884
908
|
|
|
885
909
|
if (
|
|
@@ -1004,13 +1028,14 @@ const handle = async (request, url) => {
|
|
|
1004
1028
|
self.addEventListener('fetch', (event) => {
|
|
1005
1029
|
const url = new URL(event.request.url)
|
|
1006
1030
|
if (url.origin !== self.location.origin) return
|
|
1031
|
+
const path = withoutBase(url.pathname)
|
|
1007
1032
|
|
|
1008
1033
|
// The site's own page is answered by the home route, but a navigation must
|
|
1009
1034
|
// never be at the API's mercy: if that route throws or answers an error, the
|
|
1010
1035
|
// request falls back to the static host, which holds the same index.html. A
|
|
1011
1036
|
// broken API route can cost a client its data; it must not cost the app its
|
|
1012
1037
|
// page.
|
|
1013
|
-
if (
|
|
1038
|
+
if (path === '/') {
|
|
1014
1039
|
event.respondWith(
|
|
1015
1040
|
(async () => {
|
|
1016
1041
|
try {
|
|
@@ -1031,7 +1056,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
1031
1056
|
// JSON. `/AppStream` is one of those: the package serves app deployments, but
|
|
1032
1057
|
// the listing is the server's web route, and an interface that lists apps asks
|
|
1033
1058
|
// for it on every visit.
|
|
1034
|
-
if (!/^\/(SASjsApi|SASLogon|AppStream)(\/|$)/i.test(
|
|
1059
|
+
if (!/^\/(SASjsApi|SASLogon|AppStream)(\/|$)/i.test(path)) return
|
|
1035
1060
|
event.respondWith(handle(event.request, url))
|
|
1036
1061
|
})
|
|
1037
1062
|
|
|
@@ -530,6 +530,30 @@ var EXECUTABLE_TYPES = [
|
|
|
530
530
|
'text/javascript',
|
|
531
531
|
'application/javascript'
|
|
532
532
|
];
|
|
533
|
+
/**
|
|
534
|
+
* The folder the site is published under, as a path with no trailing slash.
|
|
535
|
+
*
|
|
536
|
+
* Empty for the origin root, which is the ordinary case. A host that serves the
|
|
537
|
+
* demonstration site beside its documentation (a GitHub Pages site whose root is
|
|
538
|
+
* the docs) names the folder here, and every path the build emits follows it.
|
|
539
|
+
*/
|
|
540
|
+
var normaliseBasePath = function (value) {
|
|
541
|
+
if (!value)
|
|
542
|
+
return '';
|
|
543
|
+
var base = String(value).trim();
|
|
544
|
+
if (!base.startsWith('/'))
|
|
545
|
+
base = '/' + base;
|
|
546
|
+
base = base.replace(/\/+$/, '');
|
|
547
|
+
var segments = base.split('/').slice(1);
|
|
548
|
+
if (!segments.length ||
|
|
549
|
+
segments.some(function (segment) {
|
|
550
|
+
return !/^[A-Za-z0-9._~-]+$/.test(segment) ||
|
|
551
|
+
segment === '.' ||
|
|
552
|
+
segment === '..';
|
|
553
|
+
}))
|
|
554
|
+
throw new Error("mockWebBasePath is not a folder path: ".concat(JSON.stringify(value), ". Use a path such as \"/demo\", or leave it unset for the origin root."));
|
|
555
|
+
return base;
|
|
556
|
+
};
|
|
533
557
|
/**
|
|
534
558
|
* Tells the interface that this page is answered by the browser host.
|
|
535
559
|
*
|
|
@@ -566,12 +590,16 @@ var markBrowserHost = function (html, warnings) {
|
|
|
566
590
|
* loader. Both changes are reported when the expected markup is absent, rather
|
|
567
591
|
* than producing a site that silently talks to a real server.
|
|
568
592
|
*/
|
|
569
|
-
var patchIndexHtml = function (html, version) {
|
|
593
|
+
var patchIndexHtml = function (html, version, basePath) {
|
|
594
|
+
if (basePath === void 0) { basePath = ''; }
|
|
570
595
|
var warnings = [];
|
|
571
596
|
var patched = html;
|
|
572
597
|
patched = markBrowserHost(patched, warnings);
|
|
573
598
|
if (/<sasjs[^>]*>/i.test(patched)) {
|
|
574
|
-
|
|
599
|
+
// the app talks to the origin root by default; on a site published under a
|
|
600
|
+
// folder it has to name that folder, or its API calls leave the worker's
|
|
601
|
+
// scope and meet the static host
|
|
602
|
+
patched = patched.replace(/(<sasjs[^>]*?)\sserverurl="[^"]*"/i, "$1 serverurl=\"".concat(basePath, "\""));
|
|
575
603
|
patched = patched.replace(/(<sasjs[^>]*?)\sloginmechanism="[^"]*"/i, '$1 loginmechanism="Default"');
|
|
576
604
|
}
|
|
577
605
|
else {
|
|
@@ -646,11 +674,11 @@ var patchIndexHtml = function (html, version) {
|
|
|
646
674
|
*/
|
|
647
675
|
function buildMockSite(target) {
|
|
648
676
|
return __awaiter(this, void 0, void 0, function () {
|
|
649
|
-
var projectDir, appLocRaw, appLoc, buildFolder, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, sasPrograms, driveEntries, webFileCount, hostFolder, _b, _c, asset, hostVersion, swPath, snapshotSource, hasSnapshot, indexPath, _d, patched, warnings;
|
|
677
|
+
var projectDir, appLocRaw, appLoc, buildFolder, basePath, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, sasPrograms, driveEntries, webFileCount, hostFolder, _b, _c, asset, hostVersion, swPath, snapshotSource, hasSnapshot, indexPath, _d, patched, warnings;
|
|
650
678
|
var e_8, _e;
|
|
651
|
-
var _f, _g, _h, _j;
|
|
652
|
-
return __generator(this, function (
|
|
653
|
-
switch (
|
|
679
|
+
var _f, _g, _h, _j, _k, _l;
|
|
680
|
+
return __generator(this, function (_m) {
|
|
681
|
+
switch (_m.label) {
|
|
654
682
|
case 0:
|
|
655
683
|
projectDir = process.projectDir || process.cwd();
|
|
656
684
|
appLocRaw = target.appLoc;
|
|
@@ -662,23 +690,24 @@ function buildMockSite(target) {
|
|
|
662
690
|
path_1.default.join(projectDir, 'sasjsbuild');
|
|
663
691
|
return [4 /*yield*/, (0, utils_1.folderExists)(buildFolder)];
|
|
664
692
|
case 1:
|
|
665
|
-
if (!(
|
|
693
|
+
if (!(_m.sent())) {
|
|
666
694
|
throw new Error("Build folder not found: ".concat(buildFolder, ". Run `sasjs compile` first."));
|
|
667
695
|
}
|
|
668
|
-
|
|
696
|
+
basePath = normaliseBasePath((_f = target.streamConfig) === null || _f === void 0 ? void 0 : _f.mockWebBasePath);
|
|
697
|
+
webSourceRaw = (_g = target.streamConfig) === null || _g === void 0 ? void 0 : _g.webSourcePath;
|
|
669
698
|
if (!webSourceRaw) {
|
|
670
699
|
throw new Error('No built web app. Set streamConfig.webSourcePath on the target (for example ./client/dist), and build the frontend first.');
|
|
671
700
|
}
|
|
672
701
|
webFolder = path_1.default.resolve(projectDir, webSourceRaw);
|
|
673
702
|
return [4 /*yield*/, (0, utils_1.folderExists)(webFolder)];
|
|
674
703
|
case 2:
|
|
675
|
-
if (!(
|
|
704
|
+
if (!(_m.sent())) {
|
|
676
705
|
throw new Error("Web source folder not found: ".concat(webFolder, ". Check streamConfig.webSourcePath - build the frontend first."));
|
|
677
706
|
}
|
|
678
707
|
if (!fs_1.default.existsSync(path_1.default.join(webFolder, 'index.html'))) {
|
|
679
708
|
throw new Error("".concat(webFolder, " does not contain an index.html - is streamConfig.webSourcePath pointing at the built app?"));
|
|
680
709
|
}
|
|
681
|
-
outputFolder = ((
|
|
710
|
+
outputFolder = ((_h = target.streamConfig) === null || _h === void 0 ? void 0 : _h.mockWebFolder)
|
|
682
711
|
? path_1.default.resolve(projectDir, target.streamConfig.mockWebFolder)
|
|
683
712
|
: path_1.default.join(buildFolder, 'mock');
|
|
684
713
|
assertSafeOutputFolder(outputFolder, projectDir, buildFolder, webFolder);
|
|
@@ -693,21 +722,20 @@ function buildMockSite(target) {
|
|
|
693
722
|
jsPrograms = countPrograms(seed, '.js');
|
|
694
723
|
sasPrograms = countPrograms(seed, '.sas');
|
|
695
724
|
driveEntries = Object.keys(seed).length;
|
|
696
|
-
// A build
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
725
|
+
// A build whose services are SAS is still a site worth having, and it is built:
|
|
726
|
+
// the programs are seeded on the drive, where a visitor can open them in Studio
|
|
727
|
+
// and read them - which is how a deployment's own backend code is inspected
|
|
728
|
+
// without a SAS licence and without a login. What they cannot do is run here,
|
|
729
|
+
// so the build says so rather than letting a Run button be the surprise.
|
|
701
730
|
if (jsPrograms === 0 && sasPrograms > 0) {
|
|
702
|
-
|
|
703
|
-
'Build a target whose services are JavaScript (typically a mocks project).');
|
|
731
|
+
(_j = process.logger) === null || _j === void 0 ? void 0 : _j.warn("".concat(sasPrograms, " SAS program(s) and no JavaScript in ").concat(buildFolder, ": they are placed on the drive to read, and cannot run in a browser."));
|
|
704
732
|
}
|
|
705
733
|
// No PROGRAMS is a different thing again: a compile that produced nothing, or
|
|
706
734
|
// a stale sasjsbuild from an unrelated command - `sasjs doc` leaves a folder
|
|
707
735
|
// behind that is not a program folder but does reach the drive, so the count
|
|
708
736
|
// that decides this is of programs, not of files.
|
|
709
737
|
if (jsPrograms + sasPrograms === 0) {
|
|
710
|
-
(
|
|
738
|
+
(_k = process.logger) === null || _k === void 0 ? void 0 : _k.warn("No compiled programs in ".concat(buildFolder, ": this site ships with an empty drive. ") +
|
|
711
739
|
'That is expected for an interface with no services; for anything else, check that compile wrote the target.');
|
|
712
740
|
}
|
|
713
741
|
// The package supplies the drive rules the worker answers with, so an install
|
|
@@ -720,7 +748,7 @@ function buildMockSite(target) {
|
|
|
720
748
|
'or check that @sasjs/api still publishes build/browser.js.');
|
|
721
749
|
}
|
|
722
750
|
if (skipped.length) {
|
|
723
|
-
(
|
|
751
|
+
(_l = process.logger) === null || _l === void 0 ? void 0 : _l.warn("Left ".concat(skipped.length, " file(s) off the mock drive, over the ").concat(megabytes(exports.MAX_BINARY_SEED_BYTES), " per-file or ").concat(megabytes(exports.MAX_SEED_BYTES), " total the browser build seeds: ") +
|
|
724
752
|
"".concat(skipped
|
|
725
753
|
.slice(0, 3)
|
|
726
754
|
.map(function (file) {
|
|
@@ -767,7 +795,7 @@ function buildMockSite(target) {
|
|
|
767
795
|
case 3:
|
|
768
796
|
// 2. the linter, bundled from the installed packages so the code routes answer
|
|
769
797
|
// with the rules this CLI resolved rather than a second implementation
|
|
770
|
-
|
|
798
|
+
_m.sent();
|
|
771
799
|
// sw.js sits at the site root: a worker can only control the folder it is
|
|
772
800
|
// served from, and the app has to be controlled from /
|
|
773
801
|
fs_1.default.copyFileSync(path_1.default.join(ASSETS_FOLDER, 'sw.js'), path_1.default.join(outputFolder, 'sw.js'));
|
|
@@ -803,9 +831,9 @@ function buildMockSite(target) {
|
|
|
803
831
|
swPath = path_1.default.join(outputFolder, 'sw.js');
|
|
804
832
|
fs_1.default.writeFileSync(swPath, fs_1.default
|
|
805
833
|
.readFileSync(swPath, 'utf8')
|
|
806
|
-
.replace("importScripts('
|
|
807
|
-
.replace("importScripts('
|
|
808
|
-
.replace("importScripts('
|
|
834
|
+
.replace("importScripts('host/runtime-core.js')", "importScripts('host/runtime-core.js?v=".concat(hostVersion, "')"))
|
|
835
|
+
.replace("importScripts('host/api-browser.js')", "importScripts('host/api-browser.js?v=".concat(hostVersion, "')"))
|
|
836
|
+
.replace("importScripts('host/lint-browser.js')", "importScripts('host/lint-browser.js?v=".concat(hostVersion, "')")));
|
|
809
837
|
snapshotSource = path_1.default.join(buildFolder, 'mock-snapshot.json');
|
|
810
838
|
hasSnapshot = fs_1.default.existsSync(snapshotSource);
|
|
811
839
|
if (hasSnapshot) {
|
|
@@ -815,7 +843,7 @@ function buildMockSite(target) {
|
|
|
815
843
|
fs_1.default.writeFileSync(path_1.default.join(hostFolder, 'vfs-seed.json'), JSON.stringify({ format: exports.DRIVE_FORMAT, files: seed }));
|
|
816
844
|
fs_1.default.writeFileSync(path_1.default.join(hostFolder, 'mock-config.json'), JSON.stringify({
|
|
817
845
|
appLoc: appLoc,
|
|
818
|
-
seedPath: '
|
|
846
|
+
seedPath: 'host/vfs-seed.json',
|
|
819
847
|
driveEntries: driveEntries,
|
|
820
848
|
jsPrograms: jsPrograms,
|
|
821
849
|
programFolders: PROGRAM_FOLDERS,
|
|
@@ -823,7 +851,7 @@ function buildMockSite(target) {
|
|
|
823
851
|
builtAt: new Date().toISOString()
|
|
824
852
|
}, null, 2));
|
|
825
853
|
indexPath = path_1.default.join(outputFolder, 'index.html');
|
|
826
|
-
_d = patchIndexHtml(fs_1.default.readFileSync(indexPath, 'utf8'), hostVersion), patched = _d.patched, warnings = _d.warnings;
|
|
854
|
+
_d = patchIndexHtml(fs_1.default.readFileSync(indexPath, 'utf8'), hostVersion, basePath), patched = _d.patched, warnings = _d.warnings;
|
|
827
855
|
fs_1.default.writeFileSync(indexPath, patched);
|
|
828
856
|
warnings.forEach(function (warning) { var _a; return (_a = process.logger) === null || _a === void 0 ? void 0 : _a.warn(warning); });
|
|
829
857
|
return [2 /*return*/, {
|
|
@@ -125,7 +125,10 @@ var loadBoot = function (specs, options) {
|
|
|
125
125
|
stored[key] = value;
|
|
126
126
|
}
|
|
127
127
|
};
|
|
128
|
-
global.location = {
|
|
128
|
+
global.location = {
|
|
129
|
+
href: options.href || 'http://localhost:8123/',
|
|
130
|
+
reload: function () { return timeline.push('reload'); }
|
|
131
|
+
};
|
|
129
132
|
jest.spyOn(console, 'error').mockImplementation(function () { });
|
|
130
133
|
require('../assets/boot.js');
|
|
131
134
|
var claim = function () {
|
|
@@ -162,7 +165,7 @@ describe('the boot loader', function () {
|
|
|
162
165
|
delete global.sessionStorage;
|
|
163
166
|
delete global.location;
|
|
164
167
|
});
|
|
165
|
-
it("registers the worker
|
|
168
|
+
it("registers the worker beside the page, at the build's version", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
166
169
|
var registered;
|
|
167
170
|
return __generator(this, function (_a) {
|
|
168
171
|
switch (_a.label) {
|
|
@@ -170,15 +173,36 @@ describe('the boot loader', function () {
|
|
|
170
173
|
registered = loadBoot([]).registered;
|
|
171
174
|
return [4 /*yield*/, settle()
|
|
172
175
|
// a cached sw.js would keep a returning visitor on the previous build's
|
|
173
|
-
// worker, so the URL carries the host version and the check skips the cache
|
|
176
|
+
// worker, so the URL carries the host version and the check skips the cache.
|
|
177
|
+
// The scope is the page's own folder: the site is served from the origin
|
|
178
|
+
// root on one host and from /demo beside its docs on another.
|
|
174
179
|
];
|
|
175
180
|
case 1:
|
|
176
181
|
_a.sent();
|
|
177
182
|
// a cached sw.js would keep a returning visitor on the previous build's
|
|
178
|
-
// worker, so the URL carries the host version and the check skips the cache
|
|
183
|
+
// worker, so the URL carries the host version and the check skips the cache.
|
|
184
|
+
// The scope is the page's own folder: the site is served from the origin
|
|
185
|
+
// root on one host and from /demo beside its docs on another.
|
|
179
186
|
expect(registered).toEqual([
|
|
180
|
-
'/sw.js?v=abc123 ' +
|
|
181
|
-
JSON.stringify({ scope: '
|
|
187
|
+
'http://localhost:8123/sw.js?v=abc123 ' +
|
|
188
|
+
JSON.stringify({ scope: './', updateViaCache: 'none' })
|
|
189
|
+
]);
|
|
190
|
+
return [2 /*return*/];
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}); });
|
|
194
|
+
it('registers the worker under the folder the page is served from', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
195
|
+
var registered;
|
|
196
|
+
return __generator(this, function (_a) {
|
|
197
|
+
switch (_a.label) {
|
|
198
|
+
case 0:
|
|
199
|
+
registered = loadBoot([], { href: 'http://localhost:8123/demo/' }).registered;
|
|
200
|
+
return [4 /*yield*/, settle()];
|
|
201
|
+
case 1:
|
|
202
|
+
_a.sent();
|
|
203
|
+
expect(registered).toEqual([
|
|
204
|
+
'http://localhost:8123/demo/sw.js?v=abc123 ' +
|
|
205
|
+
JSON.stringify({ scope: './', updateViaCache: 'none' })
|
|
182
206
|
]);
|
|
183
207
|
return [2 /*return*/];
|
|
184
208
|
}
|
|
@@ -328,7 +352,7 @@ describe('the boot loader', function () {
|
|
|
328
352
|
return [4 /*yield*/, settle()];
|
|
329
353
|
case 1:
|
|
330
354
|
_a.sent();
|
|
331
|
-
expect(registered[0]).toMatch(
|
|
355
|
+
expect(registered[0]).toMatch(/\/sw\.js \{/);
|
|
332
356
|
return [2 /*return*/];
|
|
333
357
|
}
|
|
334
358
|
});
|
|
@@ -447,7 +471,10 @@ describe('the boot loader', function () {
|
|
|
447
471
|
global.navigator = {};
|
|
448
472
|
global.window = {};
|
|
449
473
|
global.sessionStorage = { getItem: function () { return null; }, setItem: function () { } };
|
|
450
|
-
global.location = {
|
|
474
|
+
global.location = {
|
|
475
|
+
href: 'http://localhost:8123/',
|
|
476
|
+
reload: function () { }
|
|
477
|
+
};
|
|
451
478
|
jest.spyOn(console, 'error').mockImplementation(function () { });
|
|
452
479
|
require('../assets/boot.js');
|
|
453
480
|
return [4 /*yield*/, settle()];
|
|
@@ -210,6 +210,32 @@ describe('page-side bridge', function () {
|
|
|
210
210
|
}
|
|
211
211
|
});
|
|
212
212
|
}); });
|
|
213
|
+
it('carries a binary file as base64 rather than as damaged text', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
214
|
+
var fetchImpl, XHR, bytes, form, xhr, _a, init, payload;
|
|
215
|
+
return __generator(this, function (_b) {
|
|
216
|
+
switch (_b.label) {
|
|
217
|
+
case 0:
|
|
218
|
+
fetchImpl = jest.fn(function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
|
|
219
|
+
return [2 /*return*/, jsonResponse('{"status":"success"}')];
|
|
220
|
+
}); }); });
|
|
221
|
+
XHR = loadBridge(fetchImpl).XHR;
|
|
222
|
+
bytes = new Uint8Array([
|
|
223
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
|
224
|
+
]);
|
|
225
|
+
form = new FormData();
|
|
226
|
+
form.append('file', new Blob([bytes], { type: 'image/png' }), 'logo.png');
|
|
227
|
+
xhr = new XHR();
|
|
228
|
+
return [4 /*yield*/, run(xhr, 'POST', '/SASjsApi/drive/file', form)];
|
|
229
|
+
case 1:
|
|
230
|
+
_b.sent();
|
|
231
|
+
_a = __read(fetchImpl.mock.calls[0], 2), init = _a[1];
|
|
232
|
+
payload = JSON.parse(init.body);
|
|
233
|
+
expect(payload.tables[0].binary).toBe(true);
|
|
234
|
+
expect(payload.tables[0].csv).toBe(btoa('PNG\r\n\n'));
|
|
235
|
+
return [2 /*return*/];
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}); });
|
|
213
239
|
it('parses the response when the app asked for json', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
214
240
|
var XHR, xhr;
|
|
215
241
|
return __generator(this, function (_a) {
|
|
@@ -208,9 +208,9 @@ describe('browser-hosted build', function () {
|
|
|
208
208
|
sw = fs_1.default.readFileSync(path_1.default.join(out(), 'sw.js'), 'utf8');
|
|
209
209
|
// both imports carry the host version: a worker caches what it imports, so
|
|
210
210
|
// a rebuild that changes either file has to change the URL
|
|
211
|
-
expect(sw).toMatch(/importScripts\('
|
|
212
|
-
expect(sw).toMatch(/importScripts\('
|
|
213
|
-
expect(sw).toMatch(/importScripts\('
|
|
211
|
+
expect(sw).toMatch(/importScripts\('host\/runtime-core\.js\?v=[0-9a-f]{10}'\)/);
|
|
212
|
+
expect(sw).toMatch(/importScripts\('host\/api-browser\.js\?v=[0-9a-f]{10}'\)/);
|
|
213
|
+
expect(sw).toMatch(/importScripts\('host\/lint-browser\.js\?v=[0-9a-f]{10}'\)/);
|
|
214
214
|
expect(sw).not.toContain("importScripts('/host/runtime-core.js')");
|
|
215
215
|
return [2 /*return*/];
|
|
216
216
|
}
|
|
@@ -366,6 +366,35 @@ describe('browser-hosted build', function () {
|
|
|
366
366
|
}
|
|
367
367
|
});
|
|
368
368
|
}); });
|
|
369
|
+
it('serves the site under a folder when the target names one', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
370
|
+
var html;
|
|
371
|
+
return __generator(this, function (_a) {
|
|
372
|
+
switch (_a.label) {
|
|
373
|
+
case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target({ mockWebBasePath: '/demo' }))];
|
|
374
|
+
case 1:
|
|
375
|
+
_a.sent();
|
|
376
|
+
html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
|
|
377
|
+
expect(html).toContain('serverurl="/demo"');
|
|
378
|
+
// the loader and the host's files stay relative to the page, so the same
|
|
379
|
+
// build works at the origin root and under the folder
|
|
380
|
+
expect(html).toContain('<script src="host/boot.js?v=');
|
|
381
|
+
return [2 /*return*/];
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}); });
|
|
385
|
+
it('refuses a base path that is not a folder path', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
386
|
+
return __generator(this, function (_a) {
|
|
387
|
+
switch (_a.label) {
|
|
388
|
+
case 0: return [4 /*yield*/, expect((0, mockSite_1.buildMockSite)(target({ mockWebBasePath: '/demo?x=1' }))).rejects.toThrow(/mockWebBasePath/)];
|
|
389
|
+
case 1:
|
|
390
|
+
_a.sent();
|
|
391
|
+
return [4 /*yield*/, expect((0, mockSite_1.buildMockSite)(target({ mockWebBasePath: '../escape' }))).rejects.toThrow(/mockWebBasePath/)];
|
|
392
|
+
case 2:
|
|
393
|
+
_a.sent();
|
|
394
|
+
return [2 /*return*/];
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}); });
|
|
369
398
|
it('points the app at itself and installs the boot loader', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
370
399
|
var html;
|
|
371
400
|
return __generator(this, function (_a) {
|
|
@@ -667,25 +696,32 @@ describe('browser-hosted build', function () {
|
|
|
667
696
|
}
|
|
668
697
|
});
|
|
669
698
|
}); });
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
699
|
+
/**
|
|
700
|
+
* A SAS backend does not run in a browser, but the programs are still worth
|
|
701
|
+
* shipping: the drive holds them, so a visitor can open them in Studio and
|
|
702
|
+
* read what a deployment's services do. The build warns and carries on.
|
|
703
|
+
*/
|
|
704
|
+
it('builds a target whose services are SAS, and warns they cannot run', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
705
|
+
var warn, result, seed;
|
|
706
|
+
var _a;
|
|
707
|
+
return __generator(this, function (_b) {
|
|
708
|
+
switch (_b.label) {
|
|
709
|
+
case 0:
|
|
677
710
|
fs_1.default.rmSync(path_1.default.join(root, 'sasjsbuild', 'services'), {
|
|
678
711
|
recursive: true,
|
|
679
712
|
force: true
|
|
680
713
|
});
|
|
681
714
|
write(path_1.default.join(root, 'sasjsbuild', 'services', 'public', 'getdata.sas'), '%put sas;\n');
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
expect(
|
|
715
|
+
warn = jest.spyOn((_a = process.logger) !== null && _a !== void 0 ? _a : console, 'warn');
|
|
716
|
+
return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
|
|
717
|
+
case 1:
|
|
718
|
+
result = _b.sent();
|
|
719
|
+
expect(result.jsPrograms).toBe(0);
|
|
720
|
+
expect(result.driveEntries).toBeGreaterThan(0);
|
|
721
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cannot run in a browser'));
|
|
722
|
+
seed = readSeed();
|
|
723
|
+
expect(Object.keys(seed.files).some(function (key) { return key.endsWith('/getdata.sas'); })).toBe(true);
|
|
724
|
+
warn.mockRestore();
|
|
689
725
|
return [2 /*return*/];
|
|
690
726
|
}
|
|
691
727
|
});
|
|
@@ -102,12 +102,14 @@ var lintBundle = function () {
|
|
|
102
102
|
}
|
|
103
103
|
return lintBundlePath;
|
|
104
104
|
};
|
|
105
|
-
var loadWorker = function () {
|
|
105
|
+
var loadWorker = function (base) {
|
|
106
|
+
if (base === void 0) { base = ''; }
|
|
106
107
|
jest.resetModules();
|
|
107
108
|
var handlers = {};
|
|
108
109
|
var refusingWrites = false;
|
|
109
110
|
var self = {
|
|
110
|
-
location
|
|
111
|
+
// the worker's own location names the folder the site is served from
|
|
112
|
+
location: { origin: origin, pathname: "".concat(base, "/sw.js") },
|
|
111
113
|
addEventListener: function (type, fn) {
|
|
112
114
|
handlers[type] = handlers[type] || [];
|
|
113
115
|
handlers[type].push(fn);
|
|
@@ -890,6 +892,58 @@ describe('service worker host', function () {
|
|
|
890
892
|
}
|
|
891
893
|
});
|
|
892
894
|
}); });
|
|
895
|
+
it('answers every route under the folder the site is published in', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
896
|
+
var worker, info, _a, page, folder, stream, landing, apps, _b;
|
|
897
|
+
var _c;
|
|
898
|
+
return __generator(this, function (_d) {
|
|
899
|
+
switch (_d.label) {
|
|
900
|
+
case 0:
|
|
901
|
+
worker = loadWorker('/demo');
|
|
902
|
+
worker.runtime.load((_c = {},
|
|
903
|
+
_c[drivePath('services/public/getdata.js')] = '_webout = "seeded";',
|
|
904
|
+
_c));
|
|
905
|
+
return [4 /*yield*/, worker.dispatch('/demo/SASjsApi/info')];
|
|
906
|
+
case 1:
|
|
907
|
+
info = _d.sent();
|
|
908
|
+
expect(info.status).toBe(200);
|
|
909
|
+
_a = expect;
|
|
910
|
+
return [4 /*yield*/, info.json()];
|
|
911
|
+
case 2:
|
|
912
|
+
_a.apply(void 0, [(_d.sent()).mode]).toBe('desktop');
|
|
913
|
+
return [4 /*yield*/, worker.dispatch('/demo/')];
|
|
914
|
+
case 3:
|
|
915
|
+
page = _d.sent();
|
|
916
|
+
expect(page.status).toBe(200);
|
|
917
|
+
expect(page.headers.get('content-type')).toContain('text/html');
|
|
918
|
+
return [4 /*yield*/, worker.dispatch('/demo')];
|
|
919
|
+
case 4:
|
|
920
|
+
folder = _d.sent();
|
|
921
|
+
expect(folder.status).toBe(200);
|
|
922
|
+
expect(folder.headers.get('content-type')).toContain('text/html');
|
|
923
|
+
return [4 /*yield*/, worker.dispatch('/demo/AppStream')];
|
|
924
|
+
case 5:
|
|
925
|
+
stream = _d.sent();
|
|
926
|
+
expect(stream.status).toBe(301);
|
|
927
|
+
expect(stream.headers.get('location')).toBe('/demo/AppStream/');
|
|
928
|
+
return [4 /*yield*/, worker.dispatch('/demo/AppStream/')];
|
|
929
|
+
case 6:
|
|
930
|
+
landing = _d.sent();
|
|
931
|
+
expect(landing.status).toBe(200);
|
|
932
|
+
expect(landing.headers.get('content-type')).toContain('text/html');
|
|
933
|
+
return [4 /*yield*/, worker.dispatch('/demo/AppStream/apps.json')];
|
|
934
|
+
case 7:
|
|
935
|
+
apps = _d.sent();
|
|
936
|
+
expect(apps.status).toBe(200);
|
|
937
|
+
_b = expect;
|
|
938
|
+
return [4 /*yield*/, apps.json()];
|
|
939
|
+
case 8:
|
|
940
|
+
_b.apply(void 0, [_d.sent()]).toEqual({ apps: [] });
|
|
941
|
+
// a path outside the folder is not the site's to answer
|
|
942
|
+
expect(worker.dispatch('/other/SASjsApi/info')).toBeUndefined();
|
|
943
|
+
return [2 /*return*/];
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
}); });
|
|
893
947
|
it('serves the build page at the home route, and falls back when it fails', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
894
948
|
var worker, response, _a, runtime, original, fallback, body;
|
|
895
949
|
return __generator(this, function (_b) {
|
|
@@ -1139,6 +1193,46 @@ describe('service worker host', function () {
|
|
|
1139
1193
|
});
|
|
1140
1194
|
}); });
|
|
1141
1195
|
});
|
|
1196
|
+
it('writes a binary upload as its bytes, not as the text it decodes to', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1197
|
+
var dispatch, image, created, read, _a, _b;
|
|
1198
|
+
return __generator(this, function (_c) {
|
|
1199
|
+
switch (_c.label) {
|
|
1200
|
+
case 0:
|
|
1201
|
+
dispatch = seeded().dispatch;
|
|
1202
|
+
image = new Uint8Array([
|
|
1203
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
|
1204
|
+
]);
|
|
1205
|
+
return [4 /*yield*/, dispatch('/SASjsApi/drive/file', {
|
|
1206
|
+
method: 'POST',
|
|
1207
|
+
headers: { 'content-type': 'application/json' },
|
|
1208
|
+
body: JSON.stringify({
|
|
1209
|
+
params: { filePath: '/Public/logo.png' },
|
|
1210
|
+
tables: [
|
|
1211
|
+
{
|
|
1212
|
+
name: 'file',
|
|
1213
|
+
filename: 'logo.png',
|
|
1214
|
+
csv: btoa(String.fromCharCode.apply(null, Array.from(image))),
|
|
1215
|
+
binary: true
|
|
1216
|
+
}
|
|
1217
|
+
]
|
|
1218
|
+
})
|
|
1219
|
+
})];
|
|
1220
|
+
case 1:
|
|
1221
|
+
created = _c.sent();
|
|
1222
|
+
expect(created.status).toBe(200);
|
|
1223
|
+
return [4 /*yield*/, dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/Public/logo.png'))];
|
|
1224
|
+
case 2:
|
|
1225
|
+
read = _c.sent();
|
|
1226
|
+
expect(read.status).toBe(200);
|
|
1227
|
+
_a = expect;
|
|
1228
|
+
_b = Uint8Array.bind;
|
|
1229
|
+
return [4 /*yield*/, read.arrayBuffer()];
|
|
1230
|
+
case 3:
|
|
1231
|
+
_a.apply(void 0, [new (_b.apply(Uint8Array, [void 0, _c.sent()]))()]).toEqual(image);
|
|
1232
|
+
return [2 /*return*/];
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
}); });
|
|
1142
1236
|
it('answers 404 for a drive file the drive does not hold', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1143
1237
|
var dispatch, response, _a;
|
|
1144
1238
|
return __generator(this, function (_b) {
|
package/package.json
CHANGED