@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
@@ -22,13 +22,25 @@ const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings,
22
22
  async function findSitemap(link, userDataDirectory, extraHTTPHeaders) {
23
23
  const homeUrl = getHomeUrl(link);
24
24
  let sitemapLink = '';
25
- const effectiveUserDataDirectory = process.env.CRAWLEE_HEADLESS === '1' ? userDataDirectory : '';
26
- const context = await constants.launcher.launchPersistentContext(effectiveUserDataDirectory, {
27
- headless: process.env.CRAWLEE_HEADLESS === '1',
28
- ...getPlaywrightLaunchOptions(browser),
29
- ...(extraHTTPHeaders && { extraHTTPHeaders }),
30
- });
31
- register(context);
25
+ const launchOptions = getPlaywrightLaunchOptions(browser);
26
+ let context;
27
+ let browserInstance;
28
+ if (process.env.CRAWLEE_HEADLESS === '1') {
29
+ const effectiveUserDataDirectory = userDataDirectory || '';
30
+ context = await constants.launcher.launchPersistentContext(effectiveUserDataDirectory, {
31
+ ...launchOptions,
32
+ ...(extraHTTPHeaders && { extraHTTPHeaders }),
33
+ });
34
+ register(context);
35
+ }
36
+ else {
37
+ // In headful mode, avoid launchPersistentContext to prevent "Browser window not found"
38
+ browserInstance = await constants.launcher.launch(launchOptions);
39
+ register(browserInstance);
40
+ context = await browserInstance.newContext({
41
+ ...(extraHTTPHeaders && { extraHTTPHeaders }),
42
+ });
43
+ }
32
44
  const page = await context.newPage();
33
45
  for (const path of sitemapPaths) {
34
46
  sitemapLink = homeUrl + path;
@@ -39,6 +51,9 @@ const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings,
39
51
  }
40
52
  await page.close();
41
53
  await context.close().catch(() => { });
54
+ if (browserInstance) {
55
+ await browserInstance.close().catch(() => { });
56
+ }
42
57
  return sitemapExist ? sitemapLink : '';
43
58
  }
44
59
  const checkUrlExists = async (page, parsedUrl) => {
@@ -1,12 +1,44 @@
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 } from '../../logs.js';
8
8
  import { guiInfoStatusTypes } from '../../constants/constants.js';
9
9
  import { isSkippedUrl, validateCustomFlowLabel } from '../../constants/common.js';
10
+ const sameRegistrableDomain = (hostA, hostB) => {
11
+ const domainA = getDomain(hostA);
12
+ const domainB = getDomain(hostB);
13
+ if (!domainA || !domainB)
14
+ return hostA === hostB;
15
+ return domainA === domainB;
16
+ };
17
+ const parseBoolEnv = (val, defaultVal) => {
18
+ if (val == null)
19
+ return defaultVal;
20
+ const v = String(val).trim().toLowerCase();
21
+ if (['1', 'true', 'yes', 'y', 'on'].includes(v))
22
+ return true;
23
+ if (['0', 'false', 'no', 'n', 'off'].includes(v))
24
+ return false;
25
+ return defaultVal;
26
+ };
27
+ const RESTRICT_OVERLAY_TO_ENTRY_DOMAIN = parseBoolEnv(process.env.RESTRICT_OVERLAY_TO_ENTRY_DOMAIN, false);
28
+ const isOverlayAllowed = (currentUrl, entryUrl) => {
29
+ try {
30
+ const cur = new URL(currentUrl);
31
+ if (cur.protocol !== 'http:' && cur.protocol !== 'https:')
32
+ return false;
33
+ if (!RESTRICT_OVERLAY_TO_ENTRY_DOMAIN)
34
+ return true;
35
+ const base = new URL(entryUrl);
36
+ return sameRegistrableDomain(cur.hostname, base.hostname);
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ };
10
42
  //! For Cypress Test
11
43
  // env to check if Cypress test is running
12
44
  const isCypressTest = process.env.IS_CYPRESS_TEST === 'true';
@@ -33,9 +65,7 @@ export const screenshotFullPage = async (page, screenshotsDir, screenshotIdx) =>
33
65
  const isLoadMoreContent = async () => new Promise(resolve => {
34
66
  setTimeout(async () => {
35
67
  await page.waitForLoadState('domcontentloaded');
36
- const newHeight = await page.evaluate(
37
- // eslint-disable-next-line no-shadow
38
- () => document.body.scrollHeight);
68
+ const newHeight = await page.evaluate(() => document.body.scrollHeight);
39
69
  const result = newHeight > prevHeight;
40
70
  resolve(result);
41
71
  }, 2500);
@@ -157,7 +187,7 @@ export const MENU_POSITION = {
157
187
  export const updateMenu = async (page, urlsCrawled) => {
158
188
  log(`Overlay menu: updating: ${page.url()}`);
159
189
  await page.evaluate(vars => {
160
- const shadowHost = document.querySelector('#oobee-shadow-host');
190
+ const shadowHost = document.querySelector('#oobeeShadowHost');
161
191
  if (shadowHost) {
162
192
  const p = shadowHost.shadowRoot.querySelector('#oobee-p-pages-scanned');
163
193
  if (p) {
@@ -200,7 +230,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
200
230
  </svg>
201
231
  `;
202
232
  minBtn.innerHTML = MINBTN_SVG;
203
- let currentPos = (vars.menuPos || 'RIGHT');
233
+ let currentPos = vars.menuPos || 'RIGHT';
204
234
  const isCollapsed = () => panel.classList.contains('collapsed');
205
235
  const setPosClass = (pos) => {
206
236
  panel.classList.remove('pos-left', 'pos-right');
@@ -217,7 +247,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
217
247
  setDraggableSidebarMenu();
218
248
  };
219
249
  const toggleCollapsed = (force) => {
220
- const willCollapse = (typeof force === 'boolean') ? force : !isCollapsed();
250
+ const willCollapse = typeof force === 'boolean' ? force : !isCollapsed();
221
251
  if (willCollapse) {
222
252
  panel.classList.add('collapsed');
223
253
  localStorage.setItem('oobee:overlay-collapsed', '1');
@@ -292,12 +322,12 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
292
322
  }
293
323
  const ol = document.createElement('ol');
294
324
  ol.className = 'oobee-ol';
295
- scanned.forEach((item) => {
325
+ scanned.forEach(item => {
296
326
  const li = document.createElement('li');
297
327
  li.className = 'oobee-li';
298
328
  const title = document.createElement('div');
299
329
  title.className = 'oobee-item-title';
300
- title.textContent = (item.pageTitle && item.pageTitle.trim()) ? item.pageTitle : item.url;
330
+ title.textContent = item.pageTitle && item.pageTitle.trim() ? item.pageTitle : item.url;
301
331
  const url = document.createElement('div');
302
332
  url.className = 'oobee-item-url';
303
333
  url.textContent = item.url;
@@ -596,8 +626,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
596
626
  if (!icon)
597
627
  return;
598
628
  const closed = isCollapsed();
599
- const arrowPointsRight = (currentPos === 'RIGHT' && !closed) ||
600
- (currentPos === 'LEFT' && closed);
629
+ const arrowPointsRight = (currentPos === 'RIGHT' && !closed) || (currentPos === 'LEFT' && closed);
601
630
  icon.classList.toggle('is-left', !arrowPointsRight);
602
631
  minBtn.setAttribute('aria-label', closed ? 'Expand panel' : 'Collapse panel');
603
632
  }
@@ -652,7 +681,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
652
681
  borderRadius: '16px',
653
682
  overflow: 'hidden',
654
683
  boxShadow: '0 10px 40px rgba(0,0,0,.35)',
655
- fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'
684
+ fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
656
685
  });
657
686
  const dialogSheet = new CSSStyleSheet();
658
687
  dialogSheet.replaceSync(`
@@ -689,12 +718,17 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
689
718
  display: 'flex',
690
719
  alignItems: 'center',
691
720
  justifyContent: 'space-between',
692
- gap: '8px'
721
+ gap: '8px',
693
722
  });
694
723
  const title = document.createElement('h2');
695
724
  title.id = 'oobee-stop-title';
696
725
  title.textContent = 'Are you sure you want to stop this scan?';
697
- Object.assign(title.style, { margin: '0', fontSize: '22px', fontWeight: '700', lineHeight: '1.25' });
726
+ Object.assign(title.style, {
727
+ margin: '0',
728
+ fontSize: '22px',
729
+ fontWeight: '700',
730
+ lineHeight: '1.25',
731
+ });
698
732
  const closeX = document.createElement('button');
699
733
  closeX.type = 'button';
700
734
  closeX.setAttribute('aria-label', 'Close');
@@ -711,13 +745,13 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
711
745
  height: '36px',
712
746
  borderRadius: '12px',
713
747
  display: 'grid',
714
- placeItems: 'center'
748
+ placeItems: 'center',
715
749
  });
716
750
  head.appendChild(title);
717
751
  head.appendChild(closeX);
718
752
  const bodyWrap = document.createElement('div');
719
753
  Object.assign(bodyWrap.style, {
720
- padding: '12px 20px 20px 20px'
754
+ padding: '12px 20px 20px 20px',
721
755
  });
722
756
  const form = document.createElement('form');
723
757
  form.noValidate = true;
@@ -725,7 +759,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
725
759
  Object.assign(form.style, {
726
760
  display: 'grid',
727
761
  gridTemplateColumns: '1fr',
728
- rowGap: '12px'
762
+ rowGap: '12px',
729
763
  });
730
764
  const label = document.createElement('label');
731
765
  label.setAttribute('for', 'oobee-stop-input');
@@ -741,7 +775,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
741
775
  padding: '12px 14px',
742
776
  fontSize: '14px',
743
777
  outline: 'none',
744
- boxSizing: 'border-box'
778
+ boxSizing: 'border-box',
745
779
  });
746
780
  input.addEventListener('focus', () => {
747
781
  input.style.borderColor = '#7b4dff';
@@ -765,7 +799,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
765
799
  fontWeight: '600',
766
800
  color: '#fff',
767
801
  background: '#9021A6',
768
- cursor: 'pointer'
802
+ cursor: 'pointer',
769
803
  });
770
804
  const cancel = document.createElement('button');
771
805
  cancel.type = 'button';
@@ -777,7 +811,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
777
811
  fontSize: '14px',
778
812
  justifySelf: 'center',
779
813
  cursor: 'pointer',
780
- padding: '6px'
814
+ padding: '6px',
781
815
  });
782
816
  actions.appendChild(primary);
783
817
  actions.appendChild(cancel);
@@ -792,10 +826,13 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
792
826
  stopDialog.appendChild(bodyWrap);
793
827
  shadowRoot.appendChild(stopDialog);
794
828
  let stopResolver = null;
795
- const hideStop = () => { try {
796
- stopDialog.close();
797
- }
798
- catch { } stopResolver = null; };
829
+ const hideStop = () => {
830
+ try {
831
+ stopDialog.close();
832
+ }
833
+ catch { }
834
+ stopResolver = null;
835
+ };
799
836
  const showStop = () => {
800
837
  if (!shouldHideInput)
801
838
  input.value = '';
@@ -813,7 +850,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
813
850
  });
814
851
  }
815
852
  };
816
- form.addEventListener('submit', (e) => {
853
+ form.addEventListener('submit', e => {
817
854
  e.preventDefault();
818
855
  const v = (input.value || '').trim();
819
856
  if (stopResolver)
@@ -830,13 +867,13 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
830
867
  stopResolver({ confirmed: false, label: '' });
831
868
  hideStop();
832
869
  });
833
- stopDialog.addEventListener('cancel', (e) => {
870
+ stopDialog.addEventListener('cancel', e => {
834
871
  e.preventDefault();
835
872
  if (stopResolver)
836
873
  stopResolver({ confirmed: false, label: '' });
837
874
  hideStop();
838
875
  });
839
- customWindow.oobeeShowStopModal = () => new Promise((resolve) => {
876
+ customWindow.oobeeShowStopModal = () => new Promise(resolve => {
840
877
  stopResolver = resolve;
841
878
  showStop();
842
879
  });
@@ -861,7 +898,7 @@ export const addOverlayMenu = async (page, urlsCrawled, menuPos, opts = {
861
898
  log('Overlay menu: successfully added');
862
899
  })
863
900
  .catch(error => {
864
- error('Overlay menu: failed to add', error);
901
+ consoleLogger.error('Overlay menu: failed to add', error);
865
902
  });
866
903
  };
867
904
  export const removeOverlayMenu = async (page) => {
@@ -910,11 +947,17 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
910
947
  await processPage(page, processPageParams);
911
948
  log('Scan: success');
912
949
  pagesDict[pageId].isScanning = false;
913
- await addOverlayMenu(page, processPageParams.urlsCrawled, menuPos, {
914
- inProgress: false,
915
- collapsed: !!pagesDict[pageId]?.collapsed,
916
- hideStopInput: !!processPageParams.customFlowLabel,
917
- });
950
+ const allowed = isOverlayAllowed(page.url(), processPageParams.entryUrl);
951
+ if (allowed) {
952
+ await addOverlayMenu(page, processPageParams.urlsCrawled, menuPos, {
953
+ inProgress: false,
954
+ collapsed: !!pagesDict[pageId]?.collapsed,
955
+ hideStopInput: !!processPageParams.customFlowLabel,
956
+ });
957
+ }
958
+ else {
959
+ await removeOverlayMenu(page);
960
+ }
918
961
  }
919
962
  catch (error) {
920
963
  log(`Scan failed ${error}`);
@@ -977,6 +1020,11 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
977
1020
  };
978
1021
  page.on('domcontentloaded', async () => {
979
1022
  try {
1023
+ const allowed = isOverlayAllowed(page.url(), processPageParams.entryUrl);
1024
+ if (!allowed) {
1025
+ await removeOverlayMenu(page);
1026
+ return;
1027
+ }
980
1028
  const existingOverlay = await page.evaluate(() => {
981
1029
  return document.querySelector('#oobeeShadowHost');
982
1030
  });
@@ -989,11 +1037,6 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
989
1037
  hideStopInput: !!processPageParams.customFlowLabel,
990
1038
  });
991
1039
  }
992
- setTimeout(() => {
993
- // Timeout here to slow things down a little
994
- }, 1000);
995
- //! For Cypress Test
996
- // Auto-clicks 'Scan this page' button only once
997
1040
  if (isCypressTest) {
998
1041
  try {
999
1042
  await handleOnScanClick();
@@ -1003,11 +1046,9 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1003
1046
  consoleLogger.info(`Error in calling handleOnScanClick, isCypressTest: ${isCypressTest}`);
1004
1047
  }
1005
1048
  }
1006
- consoleLogger.info(`Overlay state: ${existingOverlay}`);
1007
1049
  }
1008
1050
  catch {
1009
1051
  consoleLogger.info('Error in adding overlay menu to page');
1010
- consoleLogger.info('Error in adding overlay menu to page');
1011
1052
  }
1012
1053
  });
1013
1054
  await page.exposeFunction('handleOnScanClick', handleOnScanClick);
@@ -6,6 +6,7 @@ import constants, { getIntermediateScreenshotsPath, guiInfoStatusTypes, } from '
6
6
  import { initNewPage, log } from './custom/utils.js';
7
7
  import { guiInfoLog } from '../logs.js';
8
8
  import { addUrlGuardScript } from './guards/urlGuard.js';
9
+ import { getPlaywrightLaunchOptions } from '../constants/common.js';
9
10
  // Export of classes
10
11
  export class ProcessPageParams {
11
12
  constructor(scannedIdx, blacklistedPatterns, includeScreenshots, dataset, intermediateScreenshotsPath, urlsCrawled, randomToken) {
@@ -26,6 +27,7 @@ const runCustom = async (url, randomToken, viewportSettings, blacklistedPatterns
26
27
  const intermediateScreenshotsPath = getIntermediateScreenshotsPath(randomToken);
27
28
  const processPageParams = new ProcessPageParams(0, // scannedIdx
28
29
  blacklistedPatterns, includeScreenshots, dataset, intermediateScreenshotsPath, urlsCrawled, randomToken);
30
+ processPageParams.entryUrl = url;
29
31
  if (initialCustomFlowLabel && initialCustomFlowLabel.trim()) {
30
32
  processPageParams.customFlowLabel = initialCustomFlowLabel.trim();
31
33
  }
@@ -34,11 +36,18 @@ const runCustom = async (url, randomToken, viewportSettings, blacklistedPatterns
34
36
  try {
35
37
  const deviceConfig = viewportSettings.playwrightDeviceDetailsObject;
36
38
  const hasCustomViewport = !!deviceConfig;
39
+ const baseLaunchOptions = getPlaywrightLaunchOptions();
40
+ // Merge base args with custom flow specific args
41
+ const baseArgs = baseLaunchOptions.args || [];
42
+ const customArgs = hasCustomViewport ? ['--window-size=1920,1040'] : ['--start-maximized'];
43
+ const mergedArgs = [
44
+ ...baseArgs.filter(a => !a.startsWith('--window-size') && a !== '--start-maximized'),
45
+ ...customArgs,
46
+ ];
37
47
  const browser = await chromium.launch({
38
- args: hasCustomViewport ? ['--window-size=1920,1040'] : ['--start-maximized'],
48
+ ...baseLaunchOptions,
49
+ args: mergedArgs,
39
50
  headless: false,
40
- channel: 'chrome',
41
- // bypassCSP: true,
42
51
  });
43
52
  const context = await browser.newContext({
44
53
  ignoreHTTPSErrors: true,
@@ -52,8 +61,7 @@ const runCustom = async (url, randomToken, viewportSettings, blacklistedPatterns
52
61
  await context.close().catch(() => { });
53
62
  await browser.close().catch(() => { });
54
63
  }
55
- catch {
56
- }
64
+ catch { }
57
65
  };
58
66
  // For handling closing playwright browser and continue generate artifacts etc
59
67
  registerSoftClose(processPageParams.stopAll);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Builds pre-computed HTML groups to optimize Group by HTML Element functionality.
3
+ * Keys are composite "html\x00xpath" strings to ensure unique matching per element instance.
4
+ */
5
+ export const buildHtmlGroups = (rule, items, pageUrl) => {
6
+ if (!rule.htmlGroups) {
7
+ rule.htmlGroups = {};
8
+ }
9
+ items.forEach(item => {
10
+ // Use composite key of html + xpath for precise matching
11
+ const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
12
+ if (!rule.htmlGroups[htmlKey]) {
13
+ // Create new group with the first occurrence
14
+ rule.htmlGroups[htmlKey] = {
15
+ html: item.html || '',
16
+ xpath: item.xpath || '',
17
+ message: item.message || '',
18
+ screenshotPath: item.screenshotPath || '',
19
+ displayNeedsReview: item.displayNeedsReview,
20
+ pageUrls: [],
21
+ };
22
+ }
23
+ if (!rule.htmlGroups[htmlKey].pageUrls.includes(pageUrl)) {
24
+ rule.htmlGroups[htmlKey].pageUrls.push(pageUrl);
25
+ }
26
+ });
27
+ };
28
+ /**
29
+ * Converts items in pagesAffected to references (html\x00xpath composite keys) for embedding in HTML report.
30
+ * Additionally, it deep-clones allIssues, replaces page.items objects with composite reference keys.
31
+ * Those refs are specifically for htmlGroups lookup (html + xpath).
32
+ */
33
+ export const convertItemsToReferences = (allIssues) => {
34
+ const cloned = JSON.parse(JSON.stringify(allIssues));
35
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
36
+ if (!cloned.items[category]?.rules)
37
+ return;
38
+ cloned.items[category].rules.forEach((rule) => {
39
+ if (!rule.pagesAffected || !rule.htmlGroups)
40
+ return;
41
+ rule.pagesAffected.forEach((page) => {
42
+ if (!page.items)
43
+ return;
44
+ page.items = page.items.map((item) => {
45
+ if (typeof item === 'string')
46
+ return item; // Already a reference
47
+ // Use composite key matching buildHtmlGroups
48
+ const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
49
+ return htmlKey;
50
+ });
51
+ });
52
+ });
53
+ });
54
+ return cloned;
55
+ };