@govtechsg/oobee 0.10.20

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 (123) hide show
  1. package/.dockerignore +22 -0
  2. package/.github/pull_request_template.md +11 -0
  3. package/.github/workflows/docker-test.yml +54 -0
  4. package/.github/workflows/image.yml +107 -0
  5. package/.github/workflows/publish.yml +18 -0
  6. package/.idea/modules.xml +8 -0
  7. package/.idea/purple-a11y.iml +9 -0
  8. package/.idea/vcs.xml +6 -0
  9. package/.prettierrc.json +12 -0
  10. package/.vscode/extensions.json +5 -0
  11. package/.vscode/settings.json +10 -0
  12. package/CODE_OF_CONDUCT.md +128 -0
  13. package/DETAILS.md +163 -0
  14. package/Dockerfile +60 -0
  15. package/INSTALLATION.md +146 -0
  16. package/INTEGRATION.md +785 -0
  17. package/LICENSE +22 -0
  18. package/README.md +587 -0
  19. package/SECURITY.md +5 -0
  20. package/__mocks__/mock-report.html +1431 -0
  21. package/__mocks__/mockFunctions.ts +32 -0
  22. package/__mocks__/mockIssues.ts +64 -0
  23. package/__mocks__/mock_all_issues/000000001.json +64 -0
  24. package/__mocks__/mock_all_issues/000000002.json +53 -0
  25. package/__mocks__/mock_all_issues/fake-file.txt +0 -0
  26. package/__tests__/logs.test.ts +25 -0
  27. package/__tests__/mergeAxeResults.test.ts +278 -0
  28. package/__tests__/utils.test.ts +118 -0
  29. package/a11y-scan-results.zip +0 -0
  30. package/eslint.config.js +53 -0
  31. package/exclusions.txt +2 -0
  32. package/gitlab-pipeline-template.yml +54 -0
  33. package/jest.config.js +1 -0
  34. package/package.json +96 -0
  35. package/scripts/copyFiles.js +44 -0
  36. package/scripts/install_oobee_dependencies.cmd +13 -0
  37. package/scripts/install_oobee_dependencies.command +101 -0
  38. package/scripts/install_oobee_dependencies.ps1 +110 -0
  39. package/scripts/oobee_shell.cmd +13 -0
  40. package/scripts/oobee_shell.command +11 -0
  41. package/scripts/oobee_shell.sh +55 -0
  42. package/scripts/oobee_shell_ps.ps1 +54 -0
  43. package/src/cli.ts +401 -0
  44. package/src/combine.ts +240 -0
  45. package/src/constants/__tests__/common.test.ts +44 -0
  46. package/src/constants/cliFunctions.ts +305 -0
  47. package/src/constants/common.ts +1840 -0
  48. package/src/constants/constants.ts +443 -0
  49. package/src/constants/errorMeta.json +319 -0
  50. package/src/constants/itemTypeDescription.ts +11 -0
  51. package/src/constants/oobeeAi.ts +141 -0
  52. package/src/constants/questions.ts +181 -0
  53. package/src/constants/sampleData.ts +187 -0
  54. package/src/crawlers/__tests__/commonCrawlerFunc.test.ts +51 -0
  55. package/src/crawlers/commonCrawlerFunc.ts +656 -0
  56. package/src/crawlers/crawlDomain.ts +877 -0
  57. package/src/crawlers/crawlIntelligentSitemap.ts +156 -0
  58. package/src/crawlers/crawlLocalFile.ts +193 -0
  59. package/src/crawlers/crawlSitemap.ts +356 -0
  60. package/src/crawlers/custom/extractAndGradeText.ts +57 -0
  61. package/src/crawlers/custom/flagUnlabelledClickableElements.ts +964 -0
  62. package/src/crawlers/custom/utils.ts +486 -0
  63. package/src/crawlers/customAxeFunctions.ts +82 -0
  64. package/src/crawlers/pdfScanFunc.ts +468 -0
  65. package/src/crawlers/runCustom.ts +117 -0
  66. package/src/index.ts +173 -0
  67. package/src/logs.ts +66 -0
  68. package/src/mergeAxeResults.ts +964 -0
  69. package/src/npmIndex.ts +284 -0
  70. package/src/screenshotFunc/htmlScreenshotFunc.ts +411 -0
  71. package/src/screenshotFunc/pdfScreenshotFunc.ts +762 -0
  72. package/src/static/ejs/partials/components/categorySelector.ejs +4 -0
  73. package/src/static/ejs/partials/components/categorySelectorDropdown.ejs +57 -0
  74. package/src/static/ejs/partials/components/pagesScannedModal.ejs +70 -0
  75. package/src/static/ejs/partials/components/reportSearch.ejs +47 -0
  76. package/src/static/ejs/partials/components/ruleOffcanvas.ejs +105 -0
  77. package/src/static/ejs/partials/components/scanAbout.ejs +263 -0
  78. package/src/static/ejs/partials/components/screenshotLightbox.ejs +13 -0
  79. package/src/static/ejs/partials/components/summaryScanAbout.ejs +141 -0
  80. package/src/static/ejs/partials/components/summaryScanResults.ejs +16 -0
  81. package/src/static/ejs/partials/components/summaryTable.ejs +20 -0
  82. package/src/static/ejs/partials/components/summaryWcagCompliance.ejs +94 -0
  83. package/src/static/ejs/partials/components/topFive.ejs +6 -0
  84. package/src/static/ejs/partials/components/wcagCompliance.ejs +70 -0
  85. package/src/static/ejs/partials/footer.ejs +21 -0
  86. package/src/static/ejs/partials/header.ejs +230 -0
  87. package/src/static/ejs/partials/main.ejs +40 -0
  88. package/src/static/ejs/partials/scripts/bootstrap.ejs +8 -0
  89. package/src/static/ejs/partials/scripts/categorySelectorDropdownScript.ejs +190 -0
  90. package/src/static/ejs/partials/scripts/categorySummary.ejs +141 -0
  91. package/src/static/ejs/partials/scripts/highlightjs.ejs +335 -0
  92. package/src/static/ejs/partials/scripts/popper.ejs +7 -0
  93. package/src/static/ejs/partials/scripts/reportSearch.ejs +248 -0
  94. package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +801 -0
  95. package/src/static/ejs/partials/scripts/screenshotLightbox.ejs +71 -0
  96. package/src/static/ejs/partials/scripts/summaryScanResults.ejs +14 -0
  97. package/src/static/ejs/partials/scripts/summaryTable.ejs +78 -0
  98. package/src/static/ejs/partials/scripts/utils.ejs +441 -0
  99. package/src/static/ejs/partials/styles/bootstrap.ejs +12375 -0
  100. package/src/static/ejs/partials/styles/highlightjs.ejs +54 -0
  101. package/src/static/ejs/partials/styles/styles.ejs +1843 -0
  102. package/src/static/ejs/partials/styles/summaryBootstrap.ejs +12458 -0
  103. package/src/static/ejs/partials/summaryHeader.ejs +70 -0
  104. package/src/static/ejs/partials/summaryMain.ejs +75 -0
  105. package/src/static/ejs/report.ejs +420 -0
  106. package/src/static/ejs/summary.ejs +47 -0
  107. package/src/static/mustache/.prettierrc +4 -0
  108. package/src/static/mustache/Attention Deficit.mustache +11 -0
  109. package/src/static/mustache/Blind.mustache +11 -0
  110. package/src/static/mustache/Cognitive.mustache +7 -0
  111. package/src/static/mustache/Colorblindness.mustache +20 -0
  112. package/src/static/mustache/Deaf.mustache +12 -0
  113. package/src/static/mustache/Deafblind.mustache +7 -0
  114. package/src/static/mustache/Dyslexia.mustache +14 -0
  115. package/src/static/mustache/Low Vision.mustache +7 -0
  116. package/src/static/mustache/Mobility.mustache +15 -0
  117. package/src/static/mustache/Sighted Keyboard Users.mustache +42 -0
  118. package/src/static/mustache/report.mustache +1709 -0
  119. package/src/types/print-message.d.ts +28 -0
  120. package/src/types/types.ts +46 -0
  121. package/src/types/xpath-to-css.d.ts +3 -0
  122. package/src/utils.ts +332 -0
  123. package/tsconfig.json +15 -0
@@ -0,0 +1,284 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import printMessage from 'print-message';
4
+ import axe from 'axe-core';
5
+ import { fileURLToPath } from 'url';
6
+ import constants, { BrowserTypes } from './constants/constants.js';
7
+ import {
8
+ deleteClonedProfiles,
9
+ getBrowserToRun,
10
+ getPlaywrightLaunchOptions,
11
+ submitForm,
12
+ urlWithoutAuth,
13
+ } from './constants/common.js';
14
+ import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
15
+ import { createAndUpdateResultsFolders, createDetailsAndLogs } from './utils.js';
16
+ import generateArtifacts from './mergeAxeResults.js';
17
+ import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
18
+ import { silentLogger } from './logs.js';
19
+ import { alertMessageOptions } from './constants/cliFunctions.js';
20
+
21
+ const filename = fileURLToPath(import.meta.url);
22
+ const dirname = path.dirname(filename);
23
+
24
+ export const init = async (
25
+ entryUrl,
26
+ testLabel,
27
+ name = 'Your Name',
28
+ email = 'email@domain.com',
29
+ includeScreenshots = false,
30
+ viewportSettings = { width: 1000, height: 660 }, // cypress' default viewport settings
31
+ thresholds = { mustFix: undefined, goodToFix: undefined },
32
+ scanAboutMetadata = undefined,
33
+ zip = undefined,
34
+ ) => {
35
+ console.log('Starting Oobee');
36
+
37
+ const [date, time] = new Date().toLocaleString('sv').replaceAll(/-|:/g, '').split(' ');
38
+ const domain = new URL(entryUrl).hostname;
39
+ const sanitisedLabel = testLabel ? `_${testLabel.replaceAll(' ', '_')}` : '';
40
+ const randomToken = `${date}_${time}${sanitisedLabel}_${domain}`;
41
+
42
+ // max numbers of mustFix/goodToFix occurrences before test returns a fail
43
+ const { mustFix: mustFixThreshold, goodToFix: goodToFixThreshold } = thresholds;
44
+
45
+ process.env.CRAWLEE_STORAGE_DIR = randomToken;
46
+
47
+ const scanDetails = {
48
+ startTime: new Date(),
49
+ endTime: new Date(),
50
+ crawlType: 'Custom',
51
+ requestUrl: entryUrl,
52
+ urlsCrawled: { ...constants.urlsCrawledObj },
53
+ };
54
+
55
+ const urlsCrawled = { ...constants.urlsCrawledObj };
56
+
57
+ const { dataset } = await createCrawleeSubFolders(randomToken);
58
+
59
+ let mustFixIssues = 0;
60
+ let goodToFixIssues = 0;
61
+
62
+ let isInstanceTerminated = false;
63
+
64
+ const throwErrorIfTerminated = () => {
65
+ if (isInstanceTerminated) {
66
+ throw new Error('This instance of Oobee was terminated. Please start a new instance.');
67
+ }
68
+ };
69
+
70
+ const getScripts = () => {
71
+ throwErrorIfTerminated();
72
+ const axeScript = fs.readFileSync(
73
+ path.join(dirname, '../node_modules/axe-core/axe.min.js'),
74
+ 'utf-8',
75
+ );
76
+ async function runA11yScan(elementsToScan = []) {
77
+ axe.configure({
78
+ branding: {
79
+ application: 'oobee',
80
+ },
81
+ // Add custom img alt text check
82
+ checks: [
83
+ {
84
+ id: 'oobee-confusing-alt-text',
85
+ evaluate(node: HTMLElement) {
86
+ const altText = node.getAttribute('alt');
87
+ const confusingTexts = ['img', 'image', 'picture', 'photo', 'graphic'];
88
+
89
+ if (altText) {
90
+ const trimmedAltText = altText.trim().toLowerCase();
91
+ // Check if the alt text exactly matches one of the confusingTexts
92
+ if (confusingTexts.some(text => text === trimmedAltText)) {
93
+ return false; // Fail the check if the alt text is confusing or not useful
94
+ }
95
+ }
96
+
97
+ return true; // Pass the check if the alt text seems appropriate
98
+ },
99
+ metadata: {
100
+ impact: 'serious', // Set the severity to serious
101
+ messages: {
102
+ pass: 'The image alt text is probably useful',
103
+ fail: "The image alt text set as 'img', 'image', 'picture', 'photo', or 'graphic' is confusing or not useful",
104
+ },
105
+ },
106
+ },
107
+ ],
108
+ rules: [
109
+ { id: 'target-size', enabled: true },
110
+ {
111
+ id: 'oobee-confusing-alt-text',
112
+ selector: 'img[alt]',
113
+ enabled: true,
114
+ any: ['oobee-confusing-alt-text'],
115
+ all: [],
116
+ none: [],
117
+ tags: ['wcag2a', 'wcag111'],
118
+ metadata: {
119
+ description: 'Ensures image alt text is clear and useful',
120
+ help: 'Image alt text must not be vague or unhelpful',
121
+ helpUrl: 'https://www.deque.com/blog/great-alt-text-introduction/',
122
+ },
123
+ },
124
+ ],
125
+ });
126
+ const axeScanResults = await axe.run(elementsToScan, {
127
+ resultTypes: ['violations', 'passes', 'incomplete'],
128
+ });
129
+ return {
130
+ pageUrl: window.location.href,
131
+ pageTitle: document.title,
132
+ axeScanResults,
133
+ };
134
+ }
135
+ return `${axeScript} ${runA11yScan.toString()}`;
136
+ };
137
+
138
+ const pushScanResults = async (res, metadata, elementsToClick) => {
139
+ throwErrorIfTerminated();
140
+ if (includeScreenshots) {
141
+ // use chrome by default
142
+ const { browserToRun, clonedBrowserDataDir } = getBrowserToRun(BrowserTypes.CHROME);
143
+ const browserContext = await constants.launcher.launchPersistentContext(
144
+ clonedBrowserDataDir,
145
+ { viewport: scanAboutMetadata.viewport, ...getPlaywrightLaunchOptions(browserToRun) },
146
+ );
147
+ const page = await browserContext.newPage();
148
+ await page.goto(res.pageUrl);
149
+ await page.waitForLoadState('networkidle');
150
+
151
+ // click on elements to reveal hidden elements so screenshots can be taken
152
+ elementsToClick?.forEach(async elem => {
153
+ try {
154
+ await page.locator(elem).click();
155
+ } catch (e) {
156
+ silentLogger.info(e);
157
+ }
158
+ });
159
+
160
+ res.axeScanResults.violations = await takeScreenshotForHTMLElements(
161
+ res.axeScanResults.violations,
162
+ page,
163
+ randomToken,
164
+ 3000,
165
+ );
166
+ res.axeScanResults.incomplete = await takeScreenshotForHTMLElements(
167
+ res.axeScanResults.incomplete,
168
+ page,
169
+ randomToken,
170
+ 3000,
171
+ );
172
+
173
+ await browserContext.close();
174
+ deleteClonedProfiles(browserToRun);
175
+ }
176
+ const pageIndex = urlsCrawled.scanned.length + 1;
177
+ const filteredResults = filterAxeResults(res.axeScanResults, res.pageTitle, {
178
+ pageIndex,
179
+ metadata,
180
+ });
181
+ urlsCrawled.scanned.push({
182
+ url: urlWithoutAuth(res.pageUrl).toString(),
183
+ actualUrl: 'tbd',
184
+ pageTitle: `${pageIndex}: ${res.pageTitle}`,
185
+ });
186
+
187
+ mustFixIssues += filteredResults.mustFix ? filteredResults.mustFix.totalItems : 0;
188
+ goodToFixIssues += filteredResults.goodToFix ? filteredResults.goodToFix.totalItems : 0;
189
+ await dataset.pushData(filteredResults);
190
+
191
+ // return counts for users to perform custom assertions if needed
192
+ return {
193
+ mustFix: filteredResults.mustFix ? filteredResults.mustFix.totalItems : 0,
194
+ goodToFix: filteredResults.goodToFix ? filteredResults.goodToFix.totalItems : 0,
195
+ };
196
+ };
197
+
198
+ const terminate = async () => {
199
+ throwErrorIfTerminated();
200
+ console.log('Stopping Oobee');
201
+ isInstanceTerminated = true;
202
+ scanDetails.endTime = new Date();
203
+ scanDetails.urlsCrawled = urlsCrawled;
204
+
205
+ if (urlsCrawled.scanned.length === 0) {
206
+ printMessage([`No pages were scanned.`], alertMessageOptions);
207
+ } else {
208
+ await createDetailsAndLogs(randomToken);
209
+ await createAndUpdateResultsFolders(randomToken);
210
+ const pagesNotScanned = [
211
+ ...scanDetails.urlsCrawled.error,
212
+ ...scanDetails.urlsCrawled.invalid,
213
+ ];
214
+ const updatedScanAboutMetadata = {
215
+ viewport: `${viewportSettings.width} x ${viewportSettings.height}`,
216
+ ...scanAboutMetadata,
217
+ };
218
+ const basicFormHTMLSnippet = await generateArtifacts(
219
+ randomToken,
220
+ scanDetails.requestUrl,
221
+ scanDetails.crawlType,
222
+ updatedScanAboutMetadata.viewport,
223
+ scanDetails.urlsCrawled.scanned,
224
+ pagesNotScanned,
225
+ testLabel,
226
+ updatedScanAboutMetadata,
227
+ scanDetails,
228
+ zip,
229
+ );
230
+
231
+ await submitForm(
232
+ BrowserTypes.CHROMIUM, // browserToRun
233
+ '', // userDataDirectory
234
+ scanDetails.requestUrl, // scannedUrl
235
+ null, // entryUrl
236
+ scanDetails.crawlType, // scanType
237
+ email, // email
238
+ name, // name
239
+ JSON.stringify(basicFormHTMLSnippet), // scanResultsKson
240
+ urlsCrawled.scanned.length, // numberOfPagesScanned
241
+ 0,
242
+ 0,
243
+ '{}',
244
+ );
245
+ }
246
+
247
+ return randomToken;
248
+ };
249
+
250
+ const testThresholds = () => {
251
+ // check against thresholds to fail tests
252
+ let isThresholdExceeded = false;
253
+ let thresholdFailMessage = 'Exceeded thresholds:\n';
254
+ if (mustFixThreshold !== undefined && mustFixIssues > mustFixThreshold) {
255
+ isThresholdExceeded = true;
256
+ thresholdFailMessage += `mustFix occurrences found: ${mustFixIssues} > ${mustFixThreshold}\n`;
257
+ }
258
+
259
+ if (goodToFixThreshold !== undefined && goodToFixIssues > goodToFixThreshold) {
260
+ isThresholdExceeded = true;
261
+ thresholdFailMessage += `goodToFix occurrences found: ${goodToFixIssues} > ${goodToFixThreshold}\n`;
262
+ }
263
+
264
+ // uncomment to reset counts if you do not want violations count to be cumulative across other pages
265
+ // mustFixIssues = 0;
266
+ // goodToFixIssues = 0;
267
+
268
+ if (isThresholdExceeded) {
269
+ terminate(); // terminate if threshold exceeded
270
+ throw new Error(thresholdFailMessage);
271
+ }
272
+ };
273
+
274
+ return {
275
+ getScripts,
276
+ pushScanResults,
277
+ terminate,
278
+ scanDetails,
279
+ randomToken,
280
+ testThresholds,
281
+ };
282
+ };
283
+
284
+ export default init;
@@ -0,0 +1,411 @@
1
+ // import { JSDOM } from "jsdom";
2
+ import { createHash } from 'crypto';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { consoleLogger } from '../logs.js';
6
+ import { Result } from 'axe-core';
7
+ import { Page } from 'playwright';
8
+ import { NodeResultWithScreenshot, ResultWithScreenshot } from '../crawlers/commonCrawlerFunc.js';
9
+
10
+ const screenshotMap = {}; // Map of screenshot hashkey to its buffer value and screenshot path
11
+
12
+ export const takeScreenshotForHTMLElements = async (
13
+ violations: Result[],
14
+ page: Page,
15
+ randomToken: string,
16
+ locatorTimeout = 2000,
17
+ maxScreenshots = 50,
18
+ ): Promise<ResultWithScreenshot[]> => {
19
+ const newViolations: ResultWithScreenshot[] = [];
20
+ let screenshotCount = 0;
21
+
22
+ for (const violation of violations) {
23
+ if (screenshotCount >= maxScreenshots) {
24
+ consoleLogger.warn(
25
+ `Skipping screenshots for ${violation.id} as maxScreenshots (${maxScreenshots}) exceeded. You can increase it by specifying a higher value when calling takeScreenshotForHTMLElements.`,
26
+ );
27
+ newViolations.push(violation);
28
+ continue;
29
+ }
30
+
31
+ const { id: rule } = violation;
32
+
33
+ // Check if rule ID is 'oobee-grading-text-contents' and skip screenshot logic
34
+ if (rule === 'oobee-grading-text-contents') {
35
+ consoleLogger.info('Skipping screenshot for rule oobee-grading-text-contents');
36
+ newViolations.push(violation); // Make sure it gets added
37
+ continue;
38
+ }
39
+
40
+ const newViolationNodes: NodeResultWithScreenshot[] = [];
41
+ for (const node of violation.nodes) {
42
+ const nodeWithScreenshotPath: NodeResultWithScreenshot = node;
43
+ const { target } = node;
44
+ const hasValidSelector = target.length === 1 && typeof target[0] === 'string';
45
+ const selector = hasValidSelector ? (target[0] as string) : null;
46
+ if (selector) {
47
+ try {
48
+ const locator = page.locator(selector);
49
+ const locators = await locator.all();
50
+ for (const currLocator of locators) {
51
+ await currLocator.scrollIntoViewIfNeeded({ timeout: locatorTimeout });
52
+ const isVisible = await currLocator.isVisible();
53
+
54
+ if (isVisible) {
55
+ const buffer = await currLocator.screenshot({ timeout: locatorTimeout });
56
+ const screenshotPath = getScreenshotPath(buffer, randomToken);
57
+ nodeWithScreenshotPath.screenshotPath = screenshotPath;
58
+ screenshotCount++;
59
+ } else {
60
+ consoleLogger.info(`Element at ${currLocator} is not visible`);
61
+ }
62
+
63
+ break; // Stop looping after finding the first visible locator
64
+ }
65
+ } catch (e) {
66
+ consoleLogger.info(`Unable to take element screenshot at ${selector}`);
67
+ }
68
+ }
69
+ newViolationNodes.push(nodeWithScreenshotPath);
70
+ }
71
+ violation.nodes = newViolationNodes;
72
+ newViolations.push(violation);
73
+ }
74
+ // console.log('Processed Violations (after screenshots):', JSON.stringify(newViolations, null, 2));
75
+ return newViolations;
76
+ };
77
+
78
+ const generateBufferHash = (buffer: Buffer) => {
79
+ const hash = createHash('sha256');
80
+ hash.update(buffer);
81
+ return hash.digest('hex');
82
+ };
83
+
84
+ const isSameBufferHash = (buffer: Buffer, hash: string) => {
85
+ const bufferHash = generateBufferHash(buffer);
86
+ return hash === bufferHash;
87
+ };
88
+
89
+ const getIdenticalScreenshotKey = (buffer: Buffer) => {
90
+ for (const hashKey in screenshotMap) {
91
+ const isIdentical = isSameBufferHash(buffer, hashKey);
92
+ if (isIdentical) return hashKey;
93
+ }
94
+ return undefined;
95
+ };
96
+
97
+ const getScreenshotPath = (buffer: Buffer, randomToken: string) => {
98
+ let hashKey = getIdenticalScreenshotKey(buffer);
99
+ // If exists identical entry in screenshot map, get its filepath
100
+ if (hashKey) {
101
+ return screenshotMap[hashKey];
102
+ }
103
+ // Create new entry in screenshot map
104
+ hashKey = generateBufferHash(buffer);
105
+ const path = generateScreenshotPath(hashKey);
106
+ screenshotMap[hashKey] = path;
107
+
108
+ // Save image file to local storage
109
+ saveImageBufferToFile(buffer, `${randomToken}/${path}`);
110
+
111
+ return path;
112
+ };
113
+
114
+ const generateScreenshotPath = (hashKey: string) => {
115
+ return `elemScreenshots/html/${hashKey}.jpeg`;
116
+ };
117
+
118
+ const saveImageBufferToFile = (buffer: Buffer, fileName: string) => {
119
+ if (!fileName) return;
120
+ // Find and create parent directories recursively if not exist
121
+ const absPath = path.resolve(fileName);
122
+ const dir = path.dirname(absPath);
123
+ fs.mkdir(dir, { recursive: true }, err => {
124
+ if (err) console.log('Error trying to create parent directory(s):', err);
125
+ // Write the image buffer to file
126
+ fs.writeFile(absPath, buffer, err => {
127
+ if (err) console.log('Error trying to write file:', err);
128
+ });
129
+ });
130
+ };
131
+
132
+ // const hasMultipleLocators = async (locator) => await locator.count() > 1;
133
+
134
+ // const resolveMultipleLocators = async (page, locator, html) => {
135
+ // const { tag, classAttrib, hrefAttrib, textContent } = generateAttribs(html);
136
+
137
+ // const allLocators = await locator.all();
138
+ // // console.log('locator before: ', locator);
139
+ // allLocators.forEach(async currLocator => {
140
+ // console.log('curr locator: ', currLocator);
141
+ // let hrefIsExactMatch, containsTextContent;
142
+ // // if (hrefAttrib) hrefIsExactMatch = (await currLocator.getAttribute('href')) === hrefAttrib;
143
+ // if (textContent) containsTextContent = (await currLocator.innerText()).includes(textContent);
144
+
145
+ // // if (hrefAttrib && textContent) {
146
+ // // if (hrefIsExactMatch && containsTextContent) {
147
+ // // locator = currLocator;
148
+ // // console.log('1: ', locator);
149
+ // // }
150
+ // // } else if (hrefAttrib) {
151
+ // // if (hrefIsExactMatch) {
152
+ // // locator = currLocator;
153
+ // // console.log('2: ', locator);
154
+ // // }
155
+ // // } else
156
+
157
+ // if (textContent) {
158
+ // if (containsTextContent) {
159
+ // locator = currLocator;
160
+ // console.log('3: ', locator);
161
+ // }
162
+ // } else {
163
+ // locator = null;
164
+ // }
165
+ // })
166
+ // console.log('final locator: ', locator);
167
+ // return locator;
168
+ // }
169
+
170
+ // const generateAttribs = (html) => {
171
+ // const processedHTMLString = html.replaceAll('\n', '');
172
+ // const tagNamesRegex = /(?<=[<])\s*([a-zA-Z][^\s>/]*)\b/g;
173
+ // const tag = processedHTMLString.match(tagNamesRegex)[0];
174
+
175
+ // const dom = new JSDOM(processedHTMLString);
176
+ // const elem = dom.window.document.querySelector(tag);
177
+
178
+ // const textContent = elem.textContent.trim();
179
+ // const classAttrib = elem.getAttribute('class')?.trim();
180
+ // const hrefAttrib = (tag === 'a') ? elem.getAttribute('href') : null;
181
+ // console.log('text content: ', textContent);
182
+
183
+ // return {
184
+ // tag,
185
+ // ...(classAttrib && {classAttrib}),
186
+ // ...(hrefAttrib && {hrefAttrib}),
187
+ // ...(textContent && {textContent})
188
+ // }
189
+ // }
190
+
191
+ // export const takeScreenshotForHTMLElements = async (screenshotData, storagePath, browserToRun) => {
192
+ // const screenshotDir = `${storagePath}/screenshots`;
193
+ // let screenshotItems = [];
194
+ // let randomToken = `cloned-${Date.now()}`;
195
+ // const clonedDir = getClonedProfilesWithRandomToken(browserToRun, randomToken);
196
+ // const browser = await constants.launcher.launchPersistentContext(
197
+ // clonedDir,
198
+ // {
199
+ // headless: false,
200
+ // ...getPlaywrightLaunchOptions(browserToRun)
201
+ // }
202
+ // );
203
+
204
+ // for (const item of screenshotData) {
205
+ // const domain = item.url.replaceAll("https://", '').replaceAll('/', '_');
206
+ // item.htmlItems = generateSelectors(item.htmlItems);
207
+ // const page = await browser.newPage();
208
+ // await page.goto(item.url);
209
+ // let htmlItemsWithScreenshotPath = [];
210
+ // for (const htmlItem of item.htmlItems) {
211
+ // const { rule, category, selector } = htmlItem;
212
+ // const locator = await getLocators(page, selector);
213
+ // const screenshotFilePath = `${domain}/${category}/${rule}/${selector.tag}-${htmlItemsWithScreenshotPath.length}.png`;
214
+ // if (locator) {
215
+ // await locator.screenshot({ path: `${screenshotDir}/${screenshotFilePath}` });
216
+ // htmlItem.screenshotPath = `screenshots/${screenshotFilePath}`;
217
+ // }
218
+ // delete htmlItem.selector;
219
+ // htmlItemsWithScreenshotPath.push(htmlItem);
220
+ // }
221
+ // screenshotItems.push({url: item.url, htmlItems: htmlItemsWithScreenshotPath});
222
+ // await page.close();
223
+ // }
224
+ // await browser.close();
225
+ // deleteClonedProfiles(browserToRun)
226
+ // return screenshotItems;
227
+ // }
228
+
229
+ // export const processScreenshotData = (allIssues) => {
230
+ // const scannedUrls = allIssues.pagesScanned.map(page => page.url);
231
+ // const screenshotData = scannedUrls.map(scannedUrl => {
232
+ // let htmlItems = [];
233
+ // ['mustFix', 'goodToFix'].map((category) => {
234
+ // const ruleItems = allIssues.items[category].rules;
235
+ // ruleItems.map(ruleItem => {
236
+ // const { rule, pagesAffected } = ruleItem;
237
+ // pagesAffected.map(affectedPage => {
238
+ // const { url, items } = affectedPage;
239
+ // if (scannedUrl === url) {
240
+ // items.forEach(item => {if (item.html) htmlItems.push({html: item.html, rule, category})});
241
+ // }
242
+ // })
243
+ // })
244
+ // })
245
+ // return {url: scannedUrl, htmlItems};
246
+ // })
247
+ // return screenshotData;
248
+ // }
249
+
250
+ // export const getScreenshotPaths = (screenshotItems, allIssues) => {
251
+ // screenshotItems.forEach(screenshotItem => {
252
+ // const { url: ssUrl, htmlItems: ssHtmlItems } = screenshotItem;
253
+ // ssHtmlItems.map(ssHtmlItem => {
254
+ // const {
255
+ // category: ssCategory,
256
+ // rule: ssRule,
257
+ // html: ssHtml,
258
+ // screenshotPath: ssPath
259
+ // } = ssHtmlItem;
260
+ // allIssues.items[ssCategory].rules = allIssues.items[ssCategory].rules
261
+ // .map(ruleItem => {
262
+ // const { rule, pagesAffected } = ruleItem;
263
+ // if (rule === ssRule) {
264
+ // ruleItem.pagesAffected = pagesAffected.map(affectedPage => {
265
+ // const { url, items } = affectedPage;
266
+ // if (ssUrl === url) {
267
+ // affectedPage.items = items.map(htmlItem => {
268
+ // const { html } = htmlItem;
269
+ // if (ssHtml === html) htmlItem.screenshotPath = ssPath;
270
+ // return htmlItem;
271
+ // })
272
+ // }
273
+ // return affectedPage;
274
+ // })
275
+ // }
276
+ // return ruleItem;
277
+ // })
278
+ // })
279
+ // });
280
+ // }
281
+
282
+ // const generateSelectors = (htmlItems) => {
283
+ // const htmlItemsWithSelectors = htmlItems.map((htmlItem) => {
284
+ // const { html } = htmlItem;
285
+ // const processedHTMLString = html.replaceAll('\n', '');
286
+ // const tagnameRegex = /(?<=[<])\s*([a-zA-Z][^\s>/]*)\b/g;
287
+ // const tagNames = processedHTMLString.match(tagnameRegex);
288
+
289
+ // const dom = new JSDOM(processedHTMLString);
290
+ // const tag = tagNames[0]
291
+ // const elem = dom.window.document.querySelector(tag);
292
+
293
+ // const classAttrib = elem.getAttribute('class')?.trim();
294
+ // const idAttrib = elem.getAttribute('id');
295
+ // const titleAttrib = elem.getAttribute('title');
296
+ // const placeholderAttrib = elem.getAttribute('placeholder');
297
+ // const altAttrib = (tag === 'img') ? elem.getAttribute('alt') : null;
298
+ // const hrefAttrib = (tag === 'a') ? elem.getAttribute('href') : null;
299
+
300
+ // let children;
301
+ // if (tagNames.length > 1) {
302
+ // const childrenHTMLItems = Array.from(elem.children).map(child => {
303
+ // return {
304
+ // html: child.outerHTML,
305
+ // rule: htmlItem.rule,
306
+ // category: htmlItem.category
307
+ // }
308
+ // });
309
+ // children = generateSelectors(childrenHTMLItems);
310
+ // }
311
+
312
+ // let textContent = elem.textContent.trim();
313
+ // let allTextContents = [];
314
+ // children?.map((child) => {
315
+ // if (child?.selector.allTextContents) allTextContents = [...allTextContents, ...child.selector.allTextContents]
316
+ // })
317
+
318
+ // if (allTextContents.includes(textContent)) {
319
+ // textContent = null;
320
+ // } else {
321
+ // if (textContent) allTextContents = [textContent, ...allTextContents];
322
+ // }
323
+
324
+ // const selector = {
325
+ // tag,
326
+ // processedHTMLString,
327
+ // ...(textContent && {textContent}),
328
+ // ...(allTextContents.length > 0 && {allTextContents}),
329
+ // ...(classAttrib && {classAttrib}),
330
+ // ...(idAttrib && {idAttrib}),
331
+ // ...(titleAttrib && {titleAttrib}),
332
+ // ...(placeholderAttrib && {placeholderAttrib}),
333
+ // ...(altAttrib && {altAttrib}),
334
+ // ...(hrefAttrib && {hrefAttrib}),
335
+ // ...(children && {children}),
336
+ // }
337
+
338
+ // htmlItem.selector = selector;
339
+ // return htmlItem;
340
+ // })
341
+ // return htmlItemsWithSelectors;
342
+ // }
343
+
344
+ // const generateInitialLocator = (page, selector) => {
345
+ // const {
346
+ // tag,
347
+ // textContent,
348
+ // classAttrib,
349
+ // idAttrib,
350
+ // titleAttrib,
351
+ // placeholderAttrib,
352
+ // altAttrib,
353
+ // children
354
+ // } = selector;
355
+
356
+ // let locator = page.locator(tag);
357
+ // if (classAttrib) {
358
+ // const classSelector = classAttrib.replaceAll(/\s+/g, '.').replace(/^/, '.').replaceAll(':', '\\:').replaceAll('(', '\\(').replaceAll(')', '\\)');
359
+ // locator = locator.and(page.locator(classSelector))
360
+ // }
361
+ // if (idAttrib) locator = locator.and(page.locator(`#${idAttrib}`));
362
+ // if (textContent) locator = locator.and(page.getByText(textContent));
363
+ // if (titleAttrib) locator = locator.and(page.getByTitle(titleAttrib));
364
+ // if (placeholderAttrib) locator = locator.and(page.getByPlaceHolder(placeholderAttrib));
365
+ // if (altAttrib) locator = locator.and(page.getByAltText(altAttrib));
366
+
367
+ // if (children) {
368
+ // let currLocator = locator;
369
+ // for (const child of children) {
370
+ // const childLocator = generateInitialLocator(page, child.selector);
371
+ // locator = locator.and(currLocator.filter({ has: childLocator })); // figure this out tmr!
372
+ // }
373
+ // }
374
+ // return locator;
375
+ // }
376
+
377
+ // const resolveLocators = async (locator, classAttrib, hrefAttrib) => {
378
+ // const locatorCount = await locator.count();
379
+ // if (locatorCount > 1) {
380
+ // let locators = [];
381
+ // const allLocators = await locator.all();
382
+ // for (let nth = 0; nth < locatorCount; nth++) {
383
+ // const currLocator = allLocators[nth];
384
+ // const isVisible = await currLocator.isVisible();
385
+ // if (isVisible) {
386
+ // let classIsExactMatch, hrefIsExactMatch;
387
+ // if (classAttrib) classIsExactMatch = (await currLocator.getAttribute('class')) === classAttrib;
388
+ // if (hrefAttrib) hrefIsExactMatch = (await currLocator.getAttribute('href')) === hrefAttrib;
389
+
390
+ // if (classAttrib && hrefAttrib) {
391
+ // if (classIsExactMatch && hrefIsExactMatch) locators.push(currLocator);
392
+ // } else if (classAttrib) {
393
+ // if (classIsExactMatch) locators.push(currLocator);
394
+ // } else if (hrefAttrib) {
395
+ // if (hrefIsExactMatch) locators.push(currLocator);
396
+ // } else {
397
+ // locators.push(currLocator);
398
+ // }
399
+ // }
400
+ // }
401
+ // return locators.length === 1 ? locators[0] : null;
402
+ // } else {
403
+ // return locator;
404
+ // }
405
+ // }
406
+
407
+ // const getLocators = async (page, selector) => {
408
+ // const locator = generateInitialLocator(page, selector);
409
+ // const locators = await resolveLocators(locator, selector.classAttrib, selector.hrefAttrib);
410
+ // return locators;
411
+ // }