@sasjs/cli 6.28.0 → 6.30.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.
@@ -946,6 +946,58 @@ const homeService = API.createHomeService(async () => {
946
946
  }
947
947
  })
948
948
 
949
+ /**
950
+ * The lint rules a SAS file is linted against: the nearest `.sasjslint` at or
951
+ * above its folder on the drive.
952
+ *
953
+ * This is the server's rule, applied here so the demo behaves like the
954
+ * installation it demonstrates: a folder in the tree carries rules for
955
+ * everything beneath it, and changing a rule is a plain edit of that file. It
956
+ * is why the build seeds a `.sasjslint` at the drive root - a file that is
957
+ * displayed but never read would be worse than absent.
958
+ *
959
+ * Paths go through the package's own normaliser, so a backslashed or escaping
960
+ * path resolves the way the drive routes resolve it rather than by a second set
961
+ * of string rules that could disagree with them.
962
+ *
963
+ * A file that is missing, is not JSON, or parses to something that is not an
964
+ * object is skipped and the walk continues upwards - a broken rules file must
965
+ * never break linting or formatting. A walk that reaches the root with nothing
966
+ * to show returns undefined, which lints against the linter's own defaults: the
967
+ * answer for a drive with no rules file at all.
968
+ */
969
+ const LINT_CONFIG_FILE = '.sasjslint'
970
+
971
+ const lintConfig = async (filePath) => {
972
+ // A path that escapes the drive has no folder to walk from, and a buffer that
973
+ // names no file has none either: both take the drive root's rules, which is
974
+ // what a server answers for each - a refused path there falls back to the
975
+ // root file rather than to the linter's defaults.
976
+ let folder = API.dirname(API.normaliseDrivePath(filePath) ?? '/')
977
+ for (;;) {
978
+ const configPath =
979
+ folder === '/' ? `/${LINT_CONFIG_FILE}` : `${folder}/${LINT_CONFIG_FILE}`
980
+ const contents = await DRIVE_STORE.read(configPath).catch(() => null)
981
+ const parsed = parseLintConfig(contents)
982
+ if (parsed) return new CODE.LintConfig(parsed)
983
+ if (folder === '/') return undefined
984
+ folder = API.dirname(folder)
985
+ }
986
+ }
987
+
988
+ /** A `.sasjslint` as the object a configuration is built from, or null. */
989
+ const parseLintConfig = (contents) => {
990
+ if (contents === null || contents === undefined) return null
991
+ const text =
992
+ typeof contents === 'string' ? contents : new TextDecoder().decode(contents)
993
+ try {
994
+ const parsed = JSON.parse(text)
995
+ return parsed && typeof parsed === 'object' ? parsed : null
996
+ } catch (e) {
997
+ return null
998
+ }
999
+ }
1000
+
949
1001
  const SERVICES = {
950
1002
  drive: API.createDriveService(DRIVE_STORE),
951
1003
  // The package's browser fixture states one run time; this host serves two, and
@@ -970,10 +1022,11 @@ const SERVICES = {
970
1022
  directory: API.createDirectoryService(IDENTITY_STORE, directorySource),
971
1023
  appStream: APP_STREAM,
972
1024
  home: homeService,
973
- // the package's own code service over the bundled linter; the host passes no
974
- // lint config, so a buffer is linted against the linter's defaults - a mock
975
- // has no `.sasjslint` tree to resolve one from
976
- code: CODE.createCodeService({})
1025
+ // the package's own code service over the bundled linter, with the drive's
1026
+ // own rules: a buffer is linted and formatted against the nearest
1027
+ // `.sasjslint` at or above its folder, so editing that file changes the
1028
+ // editor here exactly as it does on a server
1029
+ code: CODE.createCodeService({ lintConfig })
977
1030
  }
978
1031
 
979
1032
  /** The routes this host performs itself, because the answer is not JSON. */
@@ -130,10 +130,11 @@ var toPosix = function (value) { return value.split(path_1.default.sep).join('/'
130
130
  * configuration by walking a filesystem - but the linter's core (`lintText`,
131
131
  * `formatText`) reads only the text it is given and the configuration it is
132
132
  * passed, so the two can run anywhere the module graph can be built. This
133
- * bundle is that graph: the package's own code service, the linter it wraps, and
134
- * shims for the four node modules reached at import time (`path` and `os` for
135
- * resolution, `fs` and `@sasjs/utils/file` for the config lookup that a mock
136
- * never makes, because it hands the linter the default configuration).
133
+ * bundle is that graph: the package's own code service, the configuration class
134
+ * a host builds from its own rules file, the linter behind both, and shims for
135
+ * the four node modules reached at import time (`path` and `os` for resolution,
136
+ * `fs` and `@sasjs/utils/file` for the linter's own filesystem lookup, which
137
+ * this host never makes - it hands the routes the rules it read from the drive).
137
138
  *
138
139
  * It is built from the installed @sasjs/api and @sasjs/lint on every mock
139
140
  * build, so the rules a client sees are the version the CLI resolved - a
@@ -142,8 +143,8 @@ var toPosix = function (value) { return value.split(path_1.default.sep).join('/'
142
143
  var LINT_BUNDLE_FILES = {
143
144
  'entry.js': [
144
145
  "import './process.js'",
145
- "import { createCodeService } from '@sasjs/api/code'",
146
- 'globalThis.SASjsCode = { createCodeService }'
146
+ "import { createCodeService, LintConfig } from '@sasjs/api/code'",
147
+ 'globalThis.SASjsCode = { createCodeService, LintConfig }'
147
148
  ].join('\n'),
148
149
  'process.js': [
149
150
  '// the linter reads process.env for a default and process.cwd() for a root;',
@@ -297,6 +297,7 @@ describe('browser-hosted build', function () {
297
297
  vm.createContext(realm);
298
298
  vm.runInContext(bundle, realm);
299
299
  expect(typeof realm.SASjsCode.createCodeService).toBe('function');
300
+ expect(typeof realm.SASjsCode.LintConfig).toBe('function');
300
301
  return [2 /*return*/];
301
302
  }
302
303
  });
@@ -1412,6 +1412,285 @@ describe('service worker host', function () {
1412
1412
  }
1413
1413
  });
1414
1414
  }); });
1415
+ /**
1416
+ * The rules are the drive's, not the linter's defaults.
1417
+ *
1418
+ * The Studio shows a visitor the drive's `.sasjslint` and invites them to
1419
+ * edit it, so a rules file nothing read would be a displayed lie. Each case
1420
+ * below changes a rule and reads the answer back through the route the
1421
+ * editor calls, because "the resolver was passed" is not the claim - "the
1422
+ * squiggle went away" is.
1423
+ */
1424
+ describe('the drive’s .sasjslint', function () {
1425
+ var rootConfig = '/sasjs/drive/files/.sasjslint';
1426
+ /** 81 characters: over the default 80, under the 100 these cases set. */
1427
+ var longLine = 'x'.repeat(81);
1428
+ var code = "data _null_;\n".concat(longLine, "\nrun;\n");
1429
+ var tooLong = function (diagnostics) {
1430
+ return diagnostics.filter(function (diagnostic) {
1431
+ return String(diagnostic.message).startsWith('Line exceeds maximum length');
1432
+ });
1433
+ };
1434
+ var lint = function (worker, body) { return __awaiter(void 0, void 0, void 0, function () {
1435
+ var response;
1436
+ return __generator(this, function (_a) {
1437
+ switch (_a.label) {
1438
+ case 0: return [4 /*yield*/, worker.dispatch('/SASjsApi/code/lint', runRequest(body))];
1439
+ case 1:
1440
+ response = _a.sent();
1441
+ expect(response.status).toBe(200);
1442
+ return [2 /*return*/, response.json()];
1443
+ }
1444
+ });
1445
+ }); };
1446
+ it('lints against the rules file at the drive root', function () { return __awaiter(void 0, void 0, void 0, function () {
1447
+ var worker, _a, _b, _c, _d;
1448
+ var _e, _f;
1449
+ return __generator(this, function (_g) {
1450
+ switch (_g.label) {
1451
+ case 0:
1452
+ worker = seeded();
1453
+ return [4 /*yield*/, worker.runtime.load((_e = {},
1454
+ _e[rootConfig] = JSON.stringify({ maxLineLength: 100 }),
1455
+ _e[drivePath('program.sas')] = code,
1456
+ _e))];
1457
+ case 1:
1458
+ _g.sent();
1459
+ _a = expect;
1460
+ _b = tooLong;
1461
+ return [4 /*yield*/, lint(worker, { code: code, filePath: appLoc + '/program.sas' })];
1462
+ case 2:
1463
+ _a.apply(void 0, [_b.apply(void 0, [_g.sent()])]).toHaveLength(0);
1464
+ // the same buffer with the default 80 in place: the finding the rule
1465
+ // exists to report
1466
+ return [4 /*yield*/, worker.runtime.load((_f = {},
1467
+ _f[rootConfig] = JSON.stringify({ maxLineLength: 80 }),
1468
+ _f[drivePath('program.sas')] = code,
1469
+ _f))];
1470
+ case 3:
1471
+ // the same buffer with the default 80 in place: the finding the rule
1472
+ // exists to report
1473
+ _g.sent();
1474
+ _c = expect;
1475
+ _d = tooLong;
1476
+ return [4 /*yield*/, lint(worker, { code: code, filePath: appLoc + '/program.sas' })];
1477
+ case 4:
1478
+ _c.apply(void 0, [_d.apply(void 0, [_g.sent()])]).toHaveLength(1);
1479
+ return [2 /*return*/];
1480
+ }
1481
+ });
1482
+ }); });
1483
+ it('re-reads the rules per request, so an edit takes effect', function () { return __awaiter(void 0, void 0, void 0, function () {
1484
+ var worker, path, _a, _b, form, patched, _c, _d;
1485
+ var _e;
1486
+ return __generator(this, function (_f) {
1487
+ switch (_f.label) {
1488
+ case 0:
1489
+ worker = seeded();
1490
+ path = appLoc + '/program.sas';
1491
+ return [4 /*yield*/, worker.runtime.load((_e = {},
1492
+ _e[rootConfig] = JSON.stringify({ maxLineLength: 80 }),
1493
+ _e[drivePath('program.sas')] = code,
1494
+ _e))];
1495
+ case 1:
1496
+ _f.sent();
1497
+ _a = expect;
1498
+ _b = tooLong;
1499
+ return [4 /*yield*/, lint(worker, { code: code, filePath: path })];
1500
+ case 2:
1501
+ _a.apply(void 0, [_b.apply(void 0, [_f.sent()])]).toHaveLength(1);
1502
+ form = new FormData();
1503
+ form.set('file', new Blob([JSON.stringify({ maxLineLength: 100 })], {
1504
+ type: 'application/json'
1505
+ }), '.sasjslint');
1506
+ return [4 /*yield*/, worker.dispatch("/SASjsApi/drive/file?_filePath=".concat(encodeURIComponent('/.sasjslint')), { method: 'PATCH', body: form })];
1507
+ case 3:
1508
+ patched = _f.sent();
1509
+ expect(patched.status).toBe(200);
1510
+ _c = expect;
1511
+ _d = tooLong;
1512
+ return [4 /*yield*/, lint(worker, { code: code, filePath: path })];
1513
+ case 4:
1514
+ _c.apply(void 0, [_d.apply(void 0, [_f.sent()])]).toHaveLength(0);
1515
+ return [2 /*return*/];
1516
+ }
1517
+ });
1518
+ }); });
1519
+ it('gives a folder its own rules for everything beneath it', function () { return __awaiter(void 0, void 0, void 0, function () {
1520
+ var worker, _a, _b, _c, _d;
1521
+ var _e;
1522
+ return __generator(this, function (_f) {
1523
+ switch (_f.label) {
1524
+ case 0:
1525
+ worker = seeded();
1526
+ return [4 /*yield*/, worker.runtime.load((_e = {},
1527
+ _e[rootConfig] = JSON.stringify({ maxLineLength: 80 }),
1528
+ // the nearer file relaxes the rule for this folder alone
1529
+ _e["/sasjs/drive/files".concat(appLoc, "/.sasjslint")] = JSON.stringify({
1530
+ maxLineLength: 200
1531
+ }),
1532
+ _e))];
1533
+ case 1:
1534
+ _f.sent();
1535
+ _a = expect;
1536
+ _b = tooLong;
1537
+ return [4 /*yield*/, lint(worker, {
1538
+ code: code,
1539
+ filePath: "".concat(appLoc, "/services/public/program.sas")
1540
+ })];
1541
+ case 2:
1542
+ _a.apply(void 0, [_b.apply(void 0, [_f.sent()])]).toHaveLength(0);
1543
+ // outside that folder the root file still applies
1544
+ _c = expect;
1545
+ _d = tooLong;
1546
+ return [4 /*yield*/, lint(worker, {
1547
+ code: code,
1548
+ filePath: '/home/sasjsadmin/program.sas'
1549
+ })];
1550
+ case 3:
1551
+ // outside that folder the root file still applies
1552
+ _c.apply(void 0, [_d.apply(void 0, [_f.sent()])]).toHaveLength(1);
1553
+ return [2 /*return*/];
1554
+ }
1555
+ });
1556
+ }); });
1557
+ it('finds the rules for a path spelled the way a Windows client sends it', function () { return __awaiter(void 0, void 0, void 0, function () {
1558
+ var worker, _a, _b;
1559
+ var _c;
1560
+ return __generator(this, function (_d) {
1561
+ switch (_d.label) {
1562
+ case 0:
1563
+ worker = seeded();
1564
+ return [4 /*yield*/, worker.runtime.load((_c = {},
1565
+ _c["/sasjs/drive/files".concat(appLoc, "/.sasjslint")] = JSON.stringify({
1566
+ maxLineLength: 100
1567
+ }),
1568
+ _c))];
1569
+ case 1:
1570
+ _d.sent();
1571
+ _a = expect;
1572
+ _b = tooLong;
1573
+ return [4 /*yield*/, lint(worker, {
1574
+ code: code,
1575
+ filePath: "".concat(appLoc, "\\services\\public\\program.sas")
1576
+ })];
1577
+ case 2:
1578
+ _a.apply(void 0, [_b.apply(void 0, [_d.sent()])]).toHaveLength(0);
1579
+ return [2 /*return*/];
1580
+ }
1581
+ });
1582
+ }); });
1583
+ it('skips a rules file that does not parse and uses the one above', function () { return __awaiter(void 0, void 0, void 0, function () {
1584
+ var worker, _a, _b;
1585
+ var _c;
1586
+ return __generator(this, function (_d) {
1587
+ switch (_d.label) {
1588
+ case 0:
1589
+ worker = seeded();
1590
+ return [4 /*yield*/, worker.runtime.load((_c = {},
1591
+ _c[rootConfig] = JSON.stringify({ maxLineLength: 100 }),
1592
+ _c["/sasjs/drive/files".concat(appLoc, "/.sasjslint")] = '{ this is not json',
1593
+ _c))
1594
+ // a broken near file must not break linting, and must not silently
1595
+ // replace the rules that were working
1596
+ ];
1597
+ case 1:
1598
+ _d.sent();
1599
+ // a broken near file must not break linting, and must not silently
1600
+ // replace the rules that were working
1601
+ _a = expect;
1602
+ _b = tooLong;
1603
+ return [4 /*yield*/, lint(worker, {
1604
+ code: code,
1605
+ filePath: "".concat(appLoc, "/services/public/program.sas")
1606
+ })];
1607
+ case 2:
1608
+ // a broken near file must not break linting, and must not silently
1609
+ // replace the rules that were working
1610
+ _a.apply(void 0, [_b.apply(void 0, [_d.sent()])]).toHaveLength(0);
1611
+ return [2 /*return*/];
1612
+ }
1613
+ });
1614
+ }); });
1615
+ it('takes the drive root rules for a buffer with no file, and for a path that escapes the drive', function () { return __awaiter(void 0, void 0, void 0, function () {
1616
+ var worker, _a, _b, _c, _d;
1617
+ var _e;
1618
+ return __generator(this, function (_f) {
1619
+ switch (_f.label) {
1620
+ case 0:
1621
+ worker = seeded();
1622
+ return [4 /*yield*/, worker.runtime.load((_e = {},
1623
+ _e[rootConfig] = JSON.stringify({ maxLineLength: 100 }),
1624
+ _e))];
1625
+ case 1:
1626
+ _f.sent();
1627
+ _a = expect;
1628
+ _b = tooLong;
1629
+ return [4 /*yield*/, lint(worker, { code: code })];
1630
+ case 2:
1631
+ _a.apply(void 0, [_b.apply(void 0, [_f.sent()])]).toHaveLength(0);
1632
+ _c = expect;
1633
+ _d = tooLong;
1634
+ return [4 /*yield*/, lint(worker, {
1635
+ code: code,
1636
+ filePath: '../../outside/program.sas'
1637
+ })];
1638
+ case 3:
1639
+ _c.apply(void 0, [_d.apply(void 0, [_f.sent()])]).toHaveLength(0);
1640
+ return [2 /*return*/];
1641
+ }
1642
+ });
1643
+ }); });
1644
+ it('falls back to the linter defaults when the drive has no rules file', function () { return __awaiter(void 0, void 0, void 0, function () {
1645
+ var worker, _a, _b;
1646
+ var _c;
1647
+ return __generator(this, function (_d) {
1648
+ switch (_d.label) {
1649
+ case 0:
1650
+ worker = seeded();
1651
+ return [4 /*yield*/, worker.runtime.load((_c = {}, _c[drivePath('program.sas')] = code, _c))];
1652
+ case 1:
1653
+ _d.sent();
1654
+ _a = expect;
1655
+ _b = tooLong;
1656
+ return [4 /*yield*/, lint(worker, {
1657
+ code: code,
1658
+ filePath: "".concat(appLoc, "/program.sas")
1659
+ })];
1660
+ case 2:
1661
+ _a.apply(void 0, [_b.apply(void 0, [_d.sent()])]).toHaveLength(1);
1662
+ return [2 /*return*/];
1663
+ }
1664
+ });
1665
+ }); });
1666
+ it('formats with the rules file too', function () { return __awaiter(void 0, void 0, void 0, function () {
1667
+ var worker, formatted, _a;
1668
+ var _b;
1669
+ return __generator(this, function (_c) {
1670
+ switch (_c.label) {
1671
+ case 0:
1672
+ worker = seeded();
1673
+ return [4 /*yield*/, worker.runtime.load((_b = {},
1674
+ _b[rootConfig] = JSON.stringify({
1675
+ hasDoxygenHeader: true,
1676
+ defaultHeader: '/**\n @brief the drive header\n**/'
1677
+ }),
1678
+ _b))];
1679
+ case 1:
1680
+ _c.sent();
1681
+ return [4 /*yield*/, worker.dispatch('/SASjsApi/code/format', runRequest({ code: code, filePath: '/home/sasjsadmin/program.sas' }))];
1682
+ case 2:
1683
+ formatted = _c.sent();
1684
+ expect(formatted.status).toBe(200);
1685
+ _a = expect;
1686
+ return [4 /*yield*/, formatted.text()];
1687
+ case 3:
1688
+ _a.apply(void 0, [_c.sent()]).toContain('@brief the drive header');
1689
+ return [2 /*return*/];
1690
+ }
1691
+ });
1692
+ }); });
1693
+ });
1415
1694
  it('keeps an app’s URL and tiles inside the folder the site is served from', function () { return __awaiter(void 0, void 0, void 0, function () {
1416
1695
  var worker, archive, form, published, app, listing, apps, landing, _a, page, _b;
1417
1696
  return __generator(this, function (_c) {
@@ -44,11 +44,9 @@ var path_1 = __importDefault(require("path"));
44
44
  var test_1 = require("../../../utils/test");
45
45
  var utils_1 = require("@sasjs/utils");
46
46
  var config_1 = require("../../../utils/config");
47
- var minimalAppFiles_1 = require("./minimalAppFiles");
48
- var reactAppFiles_1 = require("./reactAppFiles");
49
- var angularAppFiles_1 = require("./angularAppFiles");
50
47
  var create_1 = require("../create");
51
48
  var utils_2 = require("../../../utils");
49
+ var utils_3 = require("../../../utils/utils");
52
50
  describe('sasjs create', function () {
53
51
  beforeAll(function () {
54
52
  dotenv_1.default.config();
@@ -249,28 +247,34 @@ describe('sasjs create', function () {
249
247
  }); });
250
248
  });
251
249
  var verifyCreateWeb = function (appName, appType) { return __awaiter(void 0, void 0, void 0, function () {
252
- var fileStructure;
250
+ var template, docsCandidates, present;
253
251
  return __generator(this, function (_a) {
254
252
  switch (_a.label) {
255
- case 0:
256
- fileStructure = appType === 'minimal'
257
- ? minimalAppFiles_1.minimalAppFiles
258
- : appType === 'react'
259
- ? reactAppFiles_1.reactAppFiles
260
- : appType === 'angular'
261
- ? angularAppFiles_1.angularAppFiles
262
- : minimalAppFiles_1.minimalAppFiles;
263
- fileStructure.folderName = appName;
264
- return [4 /*yield*/, (0, test_1.verifyFolder)(fileStructure)];
253
+ case 0: return [4 /*yield*/, (0, test_1.getTemplateFileTree)(utils_3.seedAppUrls[appType])];
265
254
  case 1:
255
+ template = _a.sent();
256
+ template.folderName = appName;
257
+ return [4 /*yield*/, (0, test_1.verifyFolder)(template)];
258
+ case 2:
266
259
  _a.sent();
267
260
  return [4 /*yield*/, (0, test_1.verifyPackageJsonContent)(appName)
268
- // A seed app carries its dependencies in the template's package.json, so the
269
- // command create issues for it is a plain install - the same trade as above:
270
- // the stub writes what npm would, and this asserts the CLI asked for it.
261
+ // The docs are a submodule fetched after the archive, and the archive carries
262
+ // the folder empty - so the derived tree can only assert that the folder is
263
+ // there. A create whose docs download silently failed used to pass; assert
264
+ // the site itself arrived. Every template gets the docs (create routes all
265
+ // three through a create*App that passes the docs URL), and the path differs:
266
+ // react keeps them under `public`, the others at the root.
271
267
  ];
272
- case 2:
268
+ case 3:
273
269
  _a.sent();
270
+ docsCandidates = [
271
+ path_1.default.join(process.projectDir, appName, 'public', 'docs', 'index.html'),
272
+ path_1.default.join(process.projectDir, appName, 'docs', 'index.html')
273
+ ];
274
+ return [4 /*yield*/, Promise.all(docsCandidates.map(function (candidate) { return (0, utils_1.fileExists)(candidate); }))];
275
+ case 4:
276
+ present = _a.sent();
277
+ expect(present.some(Boolean)).toEqual(true);
274
278
  // A seed app carries its dependencies in the template's package.json, so the
275
279
  // command create issues for it is a plain install - the same trade as above:
276
280
  // the stub writes what npm would, and this asserts the CLI asked for it.