@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.
@@ -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('/sw.js' + stamp, { scope: '/', updateViaCache: 'none' })
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: await value.text()
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('/host/md5.js')
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
- const SEED_PATH = '/host/vfs-seed.json'
845
- const SNAPSHOT_PATH = '/host/vfs-snapshot.json'
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('/host/mock-config.json', { cache: 'no-store' })
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('/host/webout-prelude.json', { cache: 'no-store' })
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('/host/runtime-core.js')
37
- importScripts('/host/api-browser.js')
38
- importScripts('/host/lint-browser.js')
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('/host/mock-config.json')).json()
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('/index.html', { cache: 'no-store' })
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
- contents: table.csv === undefined ? '' : table.csv
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
- const stream = url.pathname
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: '/AppStream/' }
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 (url.pathname === '/') {
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(url.pathname)) return
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
- patched = patched.replace(/(<sasjs[^>]*?)\sserverurl="[^"]*"/i, '$1 serverurl=""');
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 (_k) {
653
- switch (_k.label) {
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 (!(_k.sent())) {
693
+ if (!(_m.sent())) {
666
694
  throw new Error("Build folder not found: ".concat(buildFolder, ". Run `sasjs compile` first."));
667
695
  }
668
- webSourceRaw = (_f = target.streamConfig) === null || _f === void 0 ? void 0 : _f.webSourcePath;
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 (!(_k.sent())) {
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 = ((_g = target.streamConfig) === null || _g === void 0 ? void 0 : _g.mockWebFolder)
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 holding SAS and no JavaScript is a SAS target: its services cannot
697
- // run here, and seeding them would produce a site whose buttons answer 400.
698
- // A build holding NEITHER is a frontend host - the app is the whole thing and
699
- // the worker answers its API - which is what an interface with no services
700
- // compiles to, and the case this site is built for.
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
- throw new Error("No .js programs in ".concat(buildFolder, "/services, /jobs or /tests, but ").concat(sasPrograms, " SAS program(s). This target builds SAS code, which cannot run in a browser. ") +
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
- (_h = process.logger) === null || _h === void 0 ? void 0 : _h.warn("No compiled programs in ".concat(buildFolder, ": this site ships with an empty drive. ") +
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
- (_j = process.logger) === null || _j === void 0 ? void 0 : _j.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: ") +
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
- _k.sent();
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('/host/runtime-core.js')", "importScripts('/host/runtime-core.js?v=".concat(hostVersion, "')"))
807
- .replace("importScripts('/host/api-browser.js')", "importScripts('/host/api-browser.js?v=".concat(hostVersion, "')"))
808
- .replace("importScripts('/host/lint-browser.js')", "importScripts('/host/lint-browser.js?v=".concat(hostVersion, "')")));
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: '/host/vfs-seed.json',
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 = { reload: function () { return timeline.push('reload'); } };
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 at the site root, at the build's version", function () { return __awaiter(void 0, void 0, void 0, function () {
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: '/', updateViaCache: 'none' })
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(/^\/sw\.js /);
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 = { reload: function () { } };
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\('\/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}'\)/);
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
- it('refuses a target whose services are SAS, and keeps the previous site', function () { return __awaiter(void 0, void 0, void 0, function () {
671
- return __generator(this, function (_a) {
672
- switch (_a.label) {
673
- case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
674
- case 1:
675
- _a.sent();
676
- write(path_1.default.join(out(), 'stale.txt'), 'previous');
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
- return [4 /*yield*/, expect((0, mockSite_1.buildMockSite)(target())).rejects.toThrow(/No \.js programs/)
683
- // the failure is not allowed to take the last good site with it
684
- ];
685
- case 2:
686
- _a.sent();
687
- // the failure is not allowed to take the last good site with it
688
- expect(fs_1.default.readFileSync(path_1.default.join(out(), 'stale.txt'), 'utf8')).toBe('previous');
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: { origin: origin },
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
@@ -125,5 +125,5 @@
125
125
  "ws": "8.21.0",
126
126
  "nanoid": "3.3.18"
127
127
  },
128
- "version": "6.17.0"
128
+ "version": "6.19.0"
129
129
  }