@govtechsg/oobee 0.10.83 → 0.10.85

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.
Files changed (36) hide show
  1. package/README.md +6 -1
  2. package/dist/cli.js +7 -6
  3. package/dist/constants/common.js +13 -1
  4. package/dist/crawlers/crawlDomain.js +220 -120
  5. package/dist/crawlers/crawlIntelligentSitemap.js +22 -7
  6. package/dist/crawlers/custom/utils.js +81 -40
  7. package/dist/crawlers/runCustom.js +13 -5
  8. package/dist/mergeAxeResults/itemReferences.js +55 -0
  9. package/dist/mergeAxeResults/jsonArtifacts.js +335 -0
  10. package/dist/mergeAxeResults/scanPages.js +159 -0
  11. package/dist/mergeAxeResults/sentryTelemetry.js +152 -0
  12. package/dist/mergeAxeResults/types.js +1 -0
  13. package/dist/mergeAxeResults/writeCsv.js +125 -0
  14. package/dist/mergeAxeResults/writeScanDetailsCsv.js +35 -0
  15. package/dist/mergeAxeResults/writeSitemap.js +10 -0
  16. package/dist/mergeAxeResults.js +64 -950
  17. package/dist/proxyService.js +90 -5
  18. package/dist/utils.js +20 -7
  19. package/package.json +6 -6
  20. package/src/cli.ts +20 -15
  21. package/src/constants/common.ts +13 -1
  22. package/src/crawlers/crawlDomain.ts +248 -137
  23. package/src/crawlers/crawlIntelligentSitemap.ts +22 -8
  24. package/src/crawlers/custom/utils.ts +103 -48
  25. package/src/crawlers/runCustom.ts +18 -5
  26. package/src/mergeAxeResults/itemReferences.ts +62 -0
  27. package/src/mergeAxeResults/jsonArtifacts.ts +451 -0
  28. package/src/mergeAxeResults/scanPages.ts +207 -0
  29. package/src/mergeAxeResults/sentryTelemetry.ts +183 -0
  30. package/src/mergeAxeResults/types.ts +99 -0
  31. package/src/mergeAxeResults/writeCsv.ts +145 -0
  32. package/src/mergeAxeResults/writeScanDetailsCsv.ts +51 -0
  33. package/src/mergeAxeResults/writeSitemap.ts +13 -0
  34. package/src/mergeAxeResults.ts +125 -1344
  35. package/src/proxyService.ts +96 -4
  36. package/src/utils.ts +19 -7
@@ -1,8 +1,8 @@
1
- /* eslint-disable no-shadow */
2
1
  /* eslint-disable no-alert */
3
2
  /* eslint-disable no-param-reassign */
4
3
  /* eslint-env browser */
5
4
  import path from 'path';
5
+ import { getDomain } from 'tldts';
6
6
  import { runAxeScript } from '../commonCrawlerFunc.js';
7
7
  import { consoleLogger, guiInfoLog, silentLogger } from '../../logs.js';
8
8
  import { guiInfoStatusTypes } from '../../constants/constants.js';
@@ -19,6 +19,44 @@ declare global {
19
19
  }
20
20
  }
21
21
 
22
+ const sameRegistrableDomain = (hostA: string, hostB: string) => {
23
+ const domainA = getDomain(hostA);
24
+ const domainB = getDomain(hostB);
25
+
26
+ if (!domainA || !domainB) return hostA === hostB;
27
+
28
+ return domainA === domainB;
29
+ };
30
+
31
+ const parseBoolEnv = (val: string | undefined, defaultVal: boolean) => {
32
+ if (val == null) return defaultVal;
33
+ const v = String(val).trim().toLowerCase();
34
+ if (['1', 'true', 'yes', 'y', 'on'].includes(v)) return true;
35
+ if (['0', 'false', 'no', 'n', 'off'].includes(v)) return false;
36
+ return defaultVal;
37
+ };
38
+
39
+ const RESTRICT_OVERLAY_TO_ENTRY_DOMAIN = parseBoolEnv(
40
+ process.env.RESTRICT_OVERLAY_TO_ENTRY_DOMAIN,
41
+ false,
42
+ );
43
+
44
+ const isOverlayAllowed = (currentUrl: string, entryUrl: string) => {
45
+ try {
46
+ const cur = new URL(currentUrl);
47
+
48
+ if (cur.protocol !== 'http:' && cur.protocol !== 'https:') return false;
49
+
50
+ if (!RESTRICT_OVERLAY_TO_ENTRY_DOMAIN) return true;
51
+
52
+ const base = new URL(entryUrl);
53
+
54
+ return sameRegistrableDomain(cur.hostname, base.hostname);
55
+ } catch {
56
+ return false;
57
+ }
58
+ };
59
+
22
60
  //! For Cypress Test
23
61
  // env to check if Cypress test is running
24
62
  const isCypressTest = process.env.IS_CYPRESS_TEST === 'true';
@@ -67,10 +105,7 @@ export const screenshotFullPage = async (page, screenshotsDir: string, screensho
67
105
  setTimeout(async () => {
68
106
  await page.waitForLoadState('domcontentloaded');
69
107
 
70
- const newHeight = await page.evaluate(
71
- // eslint-disable-next-line no-shadow
72
- () => document.body.scrollHeight,
73
- );
108
+ const newHeight = await page.evaluate(() => document.body.scrollHeight);
74
109
  const result = newHeight > prevHeight;
75
110
 
76
111
  resolve(result);
@@ -248,7 +283,7 @@ export const updateMenu = async (page, urlsCrawled) => {
248
283
  log(`Overlay menu: updating: ${page.url()}`);
249
284
  await page.evaluate(
250
285
  vars => {
251
- const shadowHost = document.querySelector('#oobee-shadow-host');
286
+ const shadowHost = document.querySelector('#oobeeShadowHost');
252
287
  if (shadowHost) {
253
288
  const p = shadowHost.shadowRoot.querySelector('#oobee-p-pages-scanned');
254
289
  if (p) {
@@ -262,7 +297,6 @@ export const updateMenu = async (page, urlsCrawled) => {
262
297
  consoleLogger.info(`Overlay menu updated`);
263
298
  };
264
299
 
265
-
266
300
  export const addOverlayMenu = async (
267
301
  page,
268
302
  urlsCrawled,
@@ -307,7 +341,7 @@ export const addOverlayMenu = async (
307
341
  `;
308
342
  minBtn.innerHTML = MINBTN_SVG;
309
343
 
310
- let currentPos: 'LEFT' | 'RIGHT' = (vars.menuPos || 'RIGHT');
344
+ let currentPos: 'LEFT' | 'RIGHT' = vars.menuPos || 'RIGHT';
311
345
  const isCollapsed = () => panel.classList.contains('collapsed');
312
346
 
313
347
  const setPosClass = (pos: 'LEFT' | 'RIGHT') => {
@@ -325,7 +359,7 @@ export const addOverlayMenu = async (
325
359
  };
326
360
 
327
361
  const toggleCollapsed = (force?: boolean) => {
328
- const willCollapse = (typeof force === 'boolean') ? force : !isCollapsed();
362
+ const willCollapse = typeof force === 'boolean' ? force : !isCollapsed();
329
363
  if (willCollapse) {
330
364
  panel.classList.add('collapsed');
331
365
  localStorage.setItem('oobee:overlay-collapsed', '1');
@@ -414,13 +448,13 @@ export const addOverlayMenu = async (
414
448
  const ol = document.createElement('ol');
415
449
  ol.className = 'oobee-ol';
416
450
 
417
- scanned.forEach((item) => {
451
+ scanned.forEach(item => {
418
452
  const li = document.createElement('li');
419
453
  li.className = 'oobee-li';
420
454
 
421
455
  const title = document.createElement('div');
422
456
  title.className = 'oobee-item-title';
423
- title.textContent = (item.pageTitle && item.pageTitle.trim()) ? item.pageTitle : item.url;
457
+ title.textContent = item.pageTitle && item.pageTitle.trim() ? item.pageTitle : item.url;
424
458
 
425
459
  const url = document.createElement('div');
426
460
  url.className = 'oobee-item-url';
@@ -730,8 +764,7 @@ export const addOverlayMenu = async (
730
764
 
731
765
  const closed = isCollapsed();
732
766
  const arrowPointsRight =
733
- (currentPos === 'RIGHT' && !closed) ||
734
- (currentPos === 'LEFT' && closed);
767
+ (currentPos === 'RIGHT' && !closed) || (currentPos === 'LEFT' && closed);
735
768
 
736
769
  icon.classList.toggle('is-left', !arrowPointsRight);
737
770
  minBtn.setAttribute('aria-label', closed ? 'Expand panel' : 'Collapse panel');
@@ -761,11 +794,11 @@ export const addOverlayMenu = async (
761
794
 
762
795
  grip.addEventListener('pointerdown', (e: PointerEvent) => {
763
796
  startX = e.clientX;
764
- grip.setPointerCapture(e.pointerId); // <-- use the button
797
+ grip.setPointerCapture(e.pointerId); // <-- use the button
765
798
  });
766
799
 
767
800
  grip.addEventListener('pointermove', (e: PointerEvent) => {
768
- if (!grip.hasPointerCapture?.(e.pointerId)) return; // <-- check the button
801
+ if (!grip.hasPointerCapture?.(e.pointerId)) return; // <-- check the button
769
802
  const dx = e.clientX - startX;
770
803
  if (Math.abs(dx) >= THRESH) {
771
804
  const nextPos: 'LEFT' | 'RIGHT' = dx < 0 ? 'LEFT' : 'RIGHT';
@@ -779,7 +812,9 @@ export const addOverlayMenu = async (
779
812
  });
780
813
 
781
814
  grip.addEventListener('pointerup', (e: PointerEvent) => {
782
- try { grip.releasePointerCapture(e.pointerId); } catch {}
815
+ try {
816
+ grip.releasePointerCapture(e.pointerId);
817
+ } catch {}
783
818
  });
784
819
 
785
820
  const stopDialog = document.createElement('dialog');
@@ -791,7 +826,7 @@ export const addOverlayMenu = async (
791
826
  borderRadius: '16px',
792
827
  overflow: 'hidden',
793
828
  boxShadow: '0 10px 40px rgba(0,0,0,.35)',
794
- fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'
829
+ fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
795
830
  });
796
831
  const dialogSheet = new CSSStyleSheet();
797
832
  dialogSheet.replaceSync(`
@@ -829,13 +864,18 @@ export const addOverlayMenu = async (
829
864
  display: 'flex',
830
865
  alignItems: 'center',
831
866
  justifyContent: 'space-between',
832
- gap: '8px'
867
+ gap: '8px',
833
868
  });
834
869
 
835
870
  const title = document.createElement('h2');
836
871
  title.id = 'oobee-stop-title';
837
872
  title.textContent = 'Are you sure you want to stop this scan?';
838
- Object.assign(title.style, { margin: '0', fontSize: '22px', fontWeight: '700', lineHeight: '1.25' });
873
+ Object.assign(title.style, {
874
+ margin: '0',
875
+ fontSize: '22px',
876
+ fontWeight: '700',
877
+ lineHeight: '1.25',
878
+ });
839
879
 
840
880
  const closeX = document.createElement('button');
841
881
  closeX.type = 'button';
@@ -853,14 +893,14 @@ export const addOverlayMenu = async (
853
893
  height: '36px',
854
894
  borderRadius: '12px',
855
895
  display: 'grid',
856
- placeItems: 'center'
896
+ placeItems: 'center',
857
897
  });
858
898
  head.appendChild(title);
859
899
  head.appendChild(closeX);
860
900
 
861
901
  const bodyWrap = document.createElement('div');
862
902
  Object.assign(bodyWrap.style, {
863
- padding: '12px 20px 20px 20px'
903
+ padding: '12px 20px 20px 20px',
864
904
  });
865
905
 
866
906
  const form = document.createElement('form');
@@ -869,7 +909,7 @@ export const addOverlayMenu = async (
869
909
  Object.assign(form.style, {
870
910
  display: 'grid',
871
911
  gridTemplateColumns: '1fr',
872
- rowGap: '12px'
912
+ rowGap: '12px',
873
913
  });
874
914
 
875
915
  const label = document.createElement('label');
@@ -887,7 +927,7 @@ export const addOverlayMenu = async (
887
927
  padding: '12px 14px',
888
928
  fontSize: '14px',
889
929
  outline: 'none',
890
- boxSizing: 'border-box'
930
+ boxSizing: 'border-box',
891
931
  });
892
932
  input.addEventListener('focus', () => {
893
933
  input.style.borderColor = '#7b4dff';
@@ -913,7 +953,7 @@ export const addOverlayMenu = async (
913
953
  fontWeight: '600',
914
954
  color: '#fff',
915
955
  background: '#9021A6',
916
- cursor: 'pointer'
956
+ cursor: 'pointer',
917
957
  });
918
958
 
919
959
  const cancel = document.createElement('button');
@@ -926,7 +966,7 @@ export const addOverlayMenu = async (
926
966
  fontSize: '14px',
927
967
  justifySelf: 'center',
928
968
  cursor: 'pointer',
929
- padding: '6px'
969
+ padding: '6px',
930
970
  });
931
971
 
932
972
  actions.appendChild(primary);
@@ -936,7 +976,7 @@ export const addOverlayMenu = async (
936
976
  form.appendChild(label);
937
977
  form.appendChild(input);
938
978
  }
939
- form.appendChild(actions);
979
+ form.appendChild(actions);
940
980
  bodyWrap.appendChild(form);
941
981
 
942
982
  stopDialog.appendChild(head);
@@ -944,17 +984,27 @@ export const addOverlayMenu = async (
944
984
  shadowRoot.appendChild(stopDialog);
945
985
 
946
986
  let stopResolver: null | ((v: { confirmed: boolean; label: string }) => void) = null;
947
- const hideStop = () => { try { stopDialog.close(); } catch {} stopResolver = null; };
987
+ const hideStop = () => {
988
+ try {
989
+ stopDialog.close();
990
+ } catch {}
991
+ stopResolver = null;
992
+ };
948
993
  const showStop = () => {
949
994
  if (!shouldHideInput) input.value = '';
950
- try { stopDialog.showModal(); } catch {}
995
+ try {
996
+ stopDialog.showModal();
997
+ } catch {}
951
998
  if (!shouldHideInput) {
952
999
  requestAnimationFrame(() => {
953
- try { input.focus({ preventScroll: true }); input.select(); } catch {}
1000
+ try {
1001
+ input.focus({ preventScroll: true });
1002
+ input.select();
1003
+ } catch {}
954
1004
  });
955
1005
  }
956
1006
  };
957
- form.addEventListener('submit', (e) => {
1007
+ form.addEventListener('submit', e => {
958
1008
  e.preventDefault();
959
1009
  const v = (input.value || '').trim();
960
1010
  if (stopResolver) stopResolver({ confirmed: true, label: v });
@@ -968,13 +1018,13 @@ export const addOverlayMenu = async (
968
1018
  if (stopResolver) stopResolver({ confirmed: false, label: '' });
969
1019
  hideStop();
970
1020
  });
971
- stopDialog.addEventListener('cancel', (e) => {
1021
+ stopDialog.addEventListener('cancel', e => {
972
1022
  e.preventDefault();
973
1023
  if (stopResolver) stopResolver({ confirmed: false, label: '' });
974
1024
  hideStop();
975
1025
  });
976
1026
  (customWindow as Window).oobeeShowStopModal = () =>
977
- new Promise<{ confirmed: boolean; label: string }>((resolve) => {
1027
+ new Promise<{ confirmed: boolean; label: string }>(resolve => {
978
1028
  stopResolver = resolve;
979
1029
  showStop();
980
1030
  });
@@ -1000,7 +1050,7 @@ export const addOverlayMenu = async (
1000
1050
  log('Overlay menu: successfully added');
1001
1051
  })
1002
1052
  .catch(error => {
1003
- error('Overlay menu: failed to add', error);
1053
+ consoleLogger.error('Overlay menu: failed to add', error);
1004
1054
  });
1005
1055
  };
1006
1056
 
@@ -1027,7 +1077,7 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1027
1077
  // eslint-disable-next-line no-underscore-dangle
1028
1078
  const pageId = page._guid;
1029
1079
 
1030
- page.on('dialog', () => { });
1080
+ page.on('dialog', () => {});
1031
1081
 
1032
1082
  const pageClosePromise = new Promise(resolve => {
1033
1083
  page.on('close', () => {
@@ -1058,11 +1108,18 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1058
1108
  await processPage(page, processPageParams);
1059
1109
  log('Scan: success');
1060
1110
  pagesDict[pageId].isScanning = false;
1061
- await addOverlayMenu(page, processPageParams.urlsCrawled, menuPos, {
1062
- inProgress: false,
1063
- collapsed: !!pagesDict[pageId]?.collapsed,
1064
- hideStopInput: !!processPageParams.customFlowLabel,
1065
- });
1111
+
1112
+ const allowed = isOverlayAllowed(page.url(), processPageParams.entryUrl);
1113
+
1114
+ if (allowed) {
1115
+ await addOverlayMenu(page, processPageParams.urlsCrawled, menuPos, {
1116
+ inProgress: false,
1117
+ collapsed: !!pagesDict[pageId]?.collapsed,
1118
+ hideStopInput: !!processPageParams.customFlowLabel,
1119
+ });
1120
+ } else {
1121
+ await removeOverlayMenu(page);
1122
+ }
1066
1123
  } catch (error) {
1067
1124
  log(`Scan failed ${error}`);
1068
1125
  }
@@ -1126,6 +1183,13 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1126
1183
 
1127
1184
  page.on('domcontentloaded', async () => {
1128
1185
  try {
1186
+ const allowed = isOverlayAllowed(page.url(), processPageParams.entryUrl);
1187
+
1188
+ if (!allowed) {
1189
+ await removeOverlayMenu(page);
1190
+ return;
1191
+ }
1192
+
1129
1193
  const existingOverlay = await page.evaluate(() => {
1130
1194
  return document.querySelector('#oobeeShadowHost');
1131
1195
  });
@@ -1141,12 +1205,6 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1141
1205
  });
1142
1206
  }
1143
1207
 
1144
- setTimeout(() => {
1145
- // Timeout here to slow things down a little
1146
- }, 1000);
1147
-
1148
- //! For Cypress Test
1149
- // Auto-clicks 'Scan this page' button only once
1150
1208
  if (isCypressTest) {
1151
1209
  try {
1152
1210
  await handleOnScanClick();
@@ -1155,11 +1213,8 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1155
1213
  consoleLogger.info(`Error in calling handleOnScanClick, isCypressTest: ${isCypressTest}`);
1156
1214
  }
1157
1215
  }
1158
-
1159
- consoleLogger.info(`Overlay state: ${existingOverlay}`);
1160
1216
  } catch {
1161
1217
  consoleLogger.info('Error in adding overlay menu to page');
1162
- consoleLogger.info('Error in adding overlay menu to page');
1163
1218
  }
1164
1219
  });
1165
1220
 
@@ -11,6 +11,7 @@ import { DEBUG, initNewPage, log } from './custom/utils.js';
11
11
  import { guiInfoLog } from '../logs.js';
12
12
  import { ViewportSettingsClass } from '../combine.js';
13
13
  import { addUrlGuardScript } from './guards/urlGuard.js';
14
+ import { getPlaywrightLaunchOptions } from '../constants/common.js';
14
15
 
15
16
  // Export of classes
16
17
 
@@ -24,6 +25,8 @@ export class ProcessPageParams {
24
25
  randomToken: string;
25
26
  customFlowLabel?: string;
26
27
  stopAll?: () => Promise<void>;
28
+ entryUrl!: string;
29
+ strategy: string;
27
30
 
28
31
  constructor(
29
32
  scannedIdx: number,
@@ -68,6 +71,8 @@ const runCustom = async (
68
71
  randomToken,
69
72
  );
70
73
 
74
+ processPageParams.entryUrl = url;
75
+
71
76
  if (initialCustomFlowLabel && initialCustomFlowLabel.trim()) {
72
77
  processPageParams.customFlowLabel = initialCustomFlowLabel.trim();
73
78
  }
@@ -79,11 +84,20 @@ const runCustom = async (
79
84
  const deviceConfig = viewportSettings.playwrightDeviceDetailsObject;
80
85
  const hasCustomViewport = !!deviceConfig;
81
86
 
87
+ const baseLaunchOptions = getPlaywrightLaunchOptions();
88
+
89
+ // Merge base args with custom flow specific args
90
+ const baseArgs = baseLaunchOptions.args || [];
91
+ const customArgs = hasCustomViewport ? ['--window-size=1920,1040'] : ['--start-maximized'];
92
+ const mergedArgs = [
93
+ ...baseArgs.filter(a => !a.startsWith('--window-size') && a !== '--start-maximized'),
94
+ ...customArgs,
95
+ ];
96
+
82
97
  const browser = await chromium.launch({
83
- args: hasCustomViewport ? ['--window-size=1920,1040'] : ['--start-maximized'],
98
+ ...baseLaunchOptions,
99
+ args: mergedArgs,
84
100
  headless: false,
85
- channel: 'chrome',
86
- // bypassCSP: true,
87
101
  });
88
102
 
89
103
  const context = await browser.newContext({
@@ -99,8 +113,7 @@ const runCustom = async (
99
113
  try {
100
114
  await context.close().catch(() => {});
101
115
  await browser.close().catch(() => {});
102
- } catch {
103
- }
116
+ } catch {}
104
117
  };
105
118
 
106
119
  // For handling closing playwright browser and continue generate artifacts etc
@@ -0,0 +1,62 @@
1
+ import type { AllIssues, ItemsInfo, RuleInfo } from './types.js';
2
+
3
+ /**
4
+ * Builds pre-computed HTML groups to optimize Group by HTML Element functionality.
5
+ * Keys are composite "html\x00xpath" strings to ensure unique matching per element instance.
6
+ */
7
+ export const buildHtmlGroups = (rule: RuleInfo, items: ItemsInfo[], pageUrl: string) => {
8
+ if (!rule.htmlGroups) {
9
+ rule.htmlGroups = {};
10
+ }
11
+
12
+ items.forEach(item => {
13
+ // Use composite key of html + xpath for precise matching
14
+ const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
15
+
16
+ if (!rule.htmlGroups![htmlKey]) {
17
+ // Create new group with the first occurrence
18
+ rule.htmlGroups![htmlKey] = {
19
+ html: item.html || '',
20
+ xpath: item.xpath || '',
21
+ message: item.message || '',
22
+ screenshotPath: item.screenshotPath || '',
23
+ displayNeedsReview: item.displayNeedsReview,
24
+ pageUrls: [],
25
+ };
26
+ }
27
+
28
+ if (!rule.htmlGroups![htmlKey].pageUrls.includes(pageUrl)) {
29
+ rule.htmlGroups![htmlKey].pageUrls.push(pageUrl);
30
+ }
31
+ });
32
+ };
33
+
34
+ /**
35
+ * Converts items in pagesAffected to references (html\x00xpath composite keys) for embedding in HTML report.
36
+ * Additionally, it deep-clones allIssues, replaces page.items objects with composite reference keys.
37
+ * Those refs are specifically for htmlGroups lookup (html + xpath).
38
+ */
39
+ export const convertItemsToReferences = (allIssues: AllIssues): AllIssues => {
40
+ const cloned = JSON.parse(JSON.stringify(allIssues));
41
+
42
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
43
+ if (!cloned.items[category]?.rules) return;
44
+
45
+ cloned.items[category].rules.forEach((rule: any) => {
46
+ if (!rule.pagesAffected || !rule.htmlGroups) return;
47
+
48
+ rule.pagesAffected.forEach((page: any) => {
49
+ if (!page.items) return;
50
+
51
+ page.items = page.items.map((item: any) => {
52
+ if (typeof item === 'string') return item; // Already a reference
53
+ // Use composite key matching buildHtmlGroups
54
+ const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
55
+ return htmlKey;
56
+ });
57
+ });
58
+ });
59
+ });
60
+
61
+ return cloned;
62
+ };