From 4fc171b08ea387431a9c628f4cae076b2ff2d019 Mon Sep 17 00:00:00 2001 From: greyguy21 <62389546+greyguy21@users.noreply.github.com> Date: Thu, 19 Oct 2023 17:41:58 +0800 Subject: [PATCH] Show Purple AI, xpath and custom flow screenshots in report and update descriptions for axe core rules (#202) * Group and display issues by screenshots for custom flow scans * Add lightbox for custom flow screenshots * Include XPath as part of JSON and HTML report * Show Purple AI when there is nointernet connection * Enable copy xpath to clipboard * Hide Purple AI feedback when there is no internet connection * Styling changes and add tooltip for copy to clipboard feature * Detect network error and display error message for AI feature in report * Fix tooltip to display copied state on click * Styling change for custom flow screenshot lightbox * Fix copy button state change * Escape white space characters in arg * Update rule descriptions for new axe core rules * Resolve merge conflict * Hide a11y online banner when it is unreachable * Revert to prod google forms --- constants/constants.js | 5 +- crawlers/commonCrawlerFunc.js | 17 +- mergeAxeResults.js | 70 ++++--- playwrightAxeGenerator.js | 157 +++++--------- .../partials/components/pagesScannedModal.ejs | 14 +- static/ejs/partials/components/scanAbout.ejs | 12 +- .../components/screenshotLightbox.ejs | 13 ++ static/ejs/partials/main.ejs | 4 + .../ejs/partials/scripts/categorySummary.ejs | 2 +- static/ejs/partials/scripts/ruleOffcanvas.ejs | 145 +++++++++++-- .../partials/scripts/screenshotLightbox.ejs | 66 ++++++ static/ejs/partials/scripts/utils.ejs | 193 +++++++++++------- static/ejs/partials/styles/styles.ejs | 99 ++++++++- static/ejs/report.ejs | 10 +- 14 files changed, 555 insertions(+), 252 deletions(-) create mode 100644 static/ejs/partials/components/screenshotLightbox.ejs create mode 100644 static/ejs/partials/scripts/screenshotLightbox.ejs diff --git a/constants/constants.js b/constants/constants.js index 9e9fc1f4..4817dfc4 100644 --- a/constants/constants.js +++ b/constants/constants.js @@ -31,7 +31,7 @@ export const blackListedFileExtensions = [ ]; export const getIntermediateScreenshotsPath = datasetsPath => `${datasetsPath}/screenshots`; -export const destinationPath = storagePath => `${storagePath}/screenshots`; +export const destinationPath = storagePath => `${storagePath}/reports/screenshots`; /** Get the path to Default Profile in the Chrome Data Directory * as per https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md @@ -229,7 +229,8 @@ export const impactOrder = { }; export const formDataFields = { - formUrl: `https://docs.google.com/forms/d/e/1FAIpQLSem5C8fyNs5TiU5Vv2Y63-SH7CHN86f-LEPxeN_1u_ldUbgUA/formResponse`, + formUrl: `https://docs.google.com/forms/d/e/1FAIpQLSem5C8fyNs5TiU5Vv2Y63-SH7CHN86f-LEPxeN_1u_ldUbgUA/formResponse`, // prod + // formUrl: `https://docs.google.com/forms/d/e/1FAIpQLScNldkNEajZbAiXK5TmMy4DfMERC2Sd7aJJrD76vBNz4pm05g/formResponse`, // dev websiteUrlField: 'entry.1562345227', scanTypeField: 'entry.1148680657', emailField: 'entry.52161304', diff --git a/crawlers/commonCrawlerFunc.js b/crawlers/commonCrawlerFunc.js index d19182a2..c0909f47 100644 --- a/crawlers/commonCrawlerFunc.js +++ b/crawlers/commonCrawlerFunc.js @@ -7,7 +7,7 @@ import { guiInfoLog } from '../logs.js'; import { takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js'; import fs from 'fs'; -export const filterAxeResults = (needsReview, results, pageTitle) => { +export const filterAxeResults = (needsReview, results, pageTitle, customFlowDetails) => { const { violations, passes, incomplete, url } = results; let totalItems = 0; @@ -36,7 +36,7 @@ export const filterAxeResults = (needsReview, results, pageTitle) => { } const addTo = (category, node) => { - const { html, failureSummary, screenshotPath } = node; + const { html, failureSummary, screenshotPath, target } = node; if (!(rule in category.rules)) { category.rules[rule] = { description, helpUrl, conformance, totalItems: 0, items: [] }; } @@ -49,12 +49,15 @@ export const filterAxeResults = (needsReview, results, pageTitle) => { finalHtml = html.replaceAll('', '</script>'); } + const xpath = target.length === 1 && typeof target[0] === 'string' ? target[0] : null; + // add in screenshot path category.rules[rule].items.push( { - html: finalHtml, + html: finalHtml, message, screenshotPath, + ...(xpath && { xpath }), ...(displayNeedsReview && { displayNeedsReview }) } ); @@ -99,7 +102,9 @@ export const filterAxeResults = (needsReview, results, pageTitle) => { return { url, - pageTitle, + pageTitle: customFlowDetails ? `${customFlowDetails.pageIndex}: ${pageTitle}` : pageTitle, + ...(customFlowDetails && { pageIndex: customFlowDetails.pageIndex }), + ...(customFlowDetails && { pageImagePath: customFlowDetails.pageImagePath }), totalItems, mustFix, goodToFix, @@ -107,7 +112,7 @@ export const filterAxeResults = (needsReview, results, pageTitle) => { }; }; -export const runAxeScript = async (needsReview, includeScreenshots, page, randomToken, selectors = []) => { +export const runAxeScript = async (needsReview, includeScreenshots, page, randomToken, customFlowDetails, selectors = []) => { await crawlee.playwrightUtils.injectFile(page, axeScript); const results = await page.evaluate( @@ -138,7 +143,7 @@ export const runAxeScript = async (needsReview, includeScreenshots, page, random } const pageTitle = await page.evaluate(() => document.title); - return filterAxeResults(needsReview, results, pageTitle); + return filterAxeResults(needsReview, results, pageTitle, customFlowDetails); }; export const createCrawleeSubFolders = async randomToken => { diff --git a/mergeAxeResults.js b/mergeAxeResults.js index 5d3510a3..f7ec2ec6 100644 --- a/mergeAxeResults.js +++ b/mergeAxeResults.js @@ -237,7 +237,7 @@ const writeSummaryPdf = async (htmlFilePath, fileDestinationPath) => { fs.unlinkSync(htmlFilePath); }; -const pushResults = async (pageResults, allIssues) => { +const pushResults = async (pageResults, allIssues, isCustomFlow) => { const { url, pageTitle, filePath } = pageResults; allIssues.totalPagesScanned += 1; @@ -278,32 +278,43 @@ const pushResults = async (pageResults, allIssues) => { currRuleFromAllIssues.totalItems += count; - if (!(url in currRuleFromAllIssues.pagesAffected)) { - currRuleFromAllIssues.pagesAffected[url] = { - pageTitle, - items: [], - ...(filePath && { filePath }), - }; - /*if (actualUrl) { - currRuleFromAllIssues.pagesAffected[url].actualUrl = actualUrl; - // Deduct duplication count from totalItems - currRuleFromAllIssues.totalItems -= 1; - // Previously using pagesAffected.length to display no. of pages affected - // However, since pagesAffected array contains duplicates, we need to deduct the duplicates - // Hence, start with negative offset, will add pagesAffected.length later - currRuleFromAllIssues.numberOfPagesAffectedAfterRedirects -= 1; - currCategoryFromAllIssues.totalItems -= 1; - }*/ + if (isCustomFlow) { + const { pageIndex, pageImagePath } = pageResults; + currRuleFromAllIssues.pagesAffected[pageIndex] = { + url, + pageTitle, + pageImagePath, + items: [], + } + currRuleFromAllIssues.pagesAffected[pageIndex].items.push(...items); + } else { + if (!(url in currRuleFromAllIssues.pagesAffected)) { + currRuleFromAllIssues.pagesAffected[url] = { + pageTitle, + items: [], + ...(filePath && { filePath }), + }; + /*if (actualUrl) { + currRuleFromAllIssues.pagesAffected[url].actualUrl = actualUrl; + // Deduct duplication count from totalItems + currRuleFromAllIssues.totalItems -= 1; + // Previously using pagesAffected.length to display no. of pages affected + // However, since pagesAffected array contains duplicates, we need to deduct the duplicates + // Hence, start with negative offset, will add pagesAffected.length later + currRuleFromAllIssues.numberOfPagesAffectedAfterRedirects -= 1; + currCategoryFromAllIssues.totalItems -= 1; + }*/ + } + + currRuleFromAllIssues.pagesAffected[url].items.push(...items); + // currRuleFromAllIssues.numberOfPagesAffectedAfterRedirects += + // currRuleFromAllIssues.pagesAffected.length; } - - currRuleFromAllIssues.pagesAffected[url].items.push(...items); - // currRuleFromAllIssues.numberOfPagesAffectedAfterRedirects += - // currRuleFromAllIssues.pagesAffected.length; }); }); }; -const flattenAndSortResults = allIssues => { +const flattenAndSortResults = (allIssues, isCustomFlow) => { ['mustFix', 'goodToFix', 'passed'].forEach(category => { allIssues.totalItems += allIssues.items[category].totalItems; allIssues.items[category].rules = Object.entries(allIssues.items[category].rules) @@ -311,8 +322,13 @@ const flattenAndSortResults = allIssues => { const [rule, ruleInfo] = ruleEntry; ruleInfo.pagesAffected = Object.entries(ruleInfo.pagesAffected) .map(pageEntry => { - const [url, pageInfo] = pageEntry; - return { url, ...pageInfo }; + if (isCustomFlow) { + const [pageIndex, pageInfo] = pageEntry; + return { pageIndex, ...pageInfo }; + } else { + const [url, pageInfo] = pageEntry; + return { url, ...pageInfo }; + } }) .sort((page1, page2) => page2.items.length - page1.items.length); return { rule, ...ruleInfo }; @@ -398,9 +414,9 @@ export const generateArtifacts = async ( goodToFix: { description: itemTypeDescription.goodToFix, totalItems: 0, rules: {} }, passed: { description: itemTypeDescription.passed, totalItems: 0, rules: {} }, }, - proxy: constants.proxy }; const allFiles = await extractFileNames(directory); + const isCustomFlow = scanType === 'Customized'; const jsonArray = await Promise.all( allFiles.map(async file => parseContentToJson(`${directory}/${file}`)), @@ -408,14 +424,14 @@ export const generateArtifacts = async ( await Promise.all( jsonArray.map(async pageResults => { - await pushResults(pageResults, allIssues); + await pushResults(pageResults, allIssues, isCustomFlow); }), ).catch(flattenIssuesError => { consoleLogger.info('An error has occurred when flattening the issues, please try again.'); silentLogger.error(flattenIssuesError.stack); }); - flattenAndSortResults(allIssues); + flattenAndSortResults(allIssues, isCustomFlow); allIssues.totalPages = allIssues.totalPagesScanned + allIssues.totalPagesNotScanned; diff --git a/playwrightAxeGenerator.js b/playwrightAxeGenerator.js index 83a96150..e6cc421a 100644 --- a/playwrightAxeGenerator.js +++ b/playwrightAxeGenerator.js @@ -111,7 +111,8 @@ const intermediateScreenshotsPath = getIntermediateScreenshotsPath( ); const checkIfScanRequired = async page => { - const imgPath = intermediateScreenshotsPath + '/PHScan-screenshot' + index.toString() + '.png'; + const imgName = 'PHScan-screenshot' + index.toString() + '.png' + const imgPath = intermediateScreenshotsPath + '/' + imgName; index += 1; @@ -191,7 +192,7 @@ const checkIfScanRequired = async page => { if (originalSize) await page.setViewportSize(originalSize); - var isSimilarPage = true; + var isSimilarPage = false; if (!urlImageDictionary[pageUrl]) { urlImageDictionary[pageUrl] = [imgPath]; @@ -199,45 +200,60 @@ const checkIfScanRequired = async page => { consoleLogger.info(\`Process page at: \${page.url()} , Scan required? true\`); silentLogger.info(\`Process page at: \${page.url()} , Scan required? true\`); - return true; + return { + scanRequired: true, + pageImagePath: \`screenshots/\${imgName}\` // relative path from reports folder + }; } else { try { const currImg = screenshotBuff; - const prevImgIdx = urlImageDictionary[pageUrl].length - 1; - const prevImg = fs.readFileSync(urlImageDictionary[pageUrl][prevImgIdx]); - const comparator = getComparator('image/png'); - console.time('Time taken'); - const isDiff = comparator(currImg, prevImg, { maxDiffPixelRatio: 0.04 }); - - if (isDiff && isDiff.errorMessage && isDiff.errorMessage.includes("ratio")) { - urlImageDictionary[pageUrl].push(imgPath) - isSimilarPage = false; - } else { + let prevImgIdx = urlImageDictionary[pageUrl].length - 1; + + while (!isSimilarPage && prevImgIdx >= 0) { + const prevImg = fs.readFileSync(urlImageDictionary[pageUrl][prevImgIdx]); + const comparator = getComparator('image/png'); + console.time('Time taken'); + const isDiff = comparator(currImg, prevImg, { maxDiffPixelRatio: 0.04 }); + + if (isDiff && isDiff.errorMessage && isDiff.errorMessage.includes('ratio')) { + prevImgIdx--; + } else { + isSimilarPage = true; + } + + console.timeEnd('Time taken'); + } + + if (isSimilarPage) { // Delete screenshot fs.unlink(imgPath, err => { if (err) throw err; }); - - isSimilarPage = true; + } else { + urlImageDictionary[pageUrl].push(imgPath) } consoleLogger.info(\`Process page at: \${page.url()} , Scan required? \${!isSimilarPage}\`); silentLogger.info(\`Process page at: \${page.url()} , Scan required? \${!isSimilarPage}\`); - - console.timeEnd('Time taken'); + return { + scanRequired: !isSimilarPage, + ...(!isSimilarPage && { pageImagePath: \`screenshots/\${imgName}\`}) + }; } catch (error) { console.error('error: ', error); } - - return !isSimilarPage; } }; -const runAxeScan = async (needsReviewItems, includeScreenshots, page) => { - const result = await runAxeScript(needsReviewItems, includeScreenshots, page, ${formatScriptStringVar(randomToken)}); +const runAxeScan = async (needsReviewItems, includeScreenshots, page, customFlowDetails) => { + const result = await runAxeScript(needsReviewItems, includeScreenshots, page, ${formatScriptStringVar(randomToken)}, customFlowDetails); await dataset.pushData(result); - urlsCrawled.scanned.push({ url: page.url(), pageTitle: result.pageTitle }); + urlsCrawled.scanned.push({ + url: page.url(), + pageTitle: result.pageTitle, + pageImagePath: customFlowDetails.pageImagePath +}); } @@ -256,14 +272,14 @@ const processPage = async page => { urlsCrawled.userExcluded.push(pageUrl) return; } else { - const scanRequired = await checkIfScanRequired(page); + const { scanRequired, pageImagePath } = await checkIfScanRequired(page); if (scanRequired) { guiInfoLog(guiInfoStatusTypes.SCANNED, { numScanned: urlsCrawled.scanned.length, urlScanned: pageUrl, }); - await runAxeScan(${needsReviewItems}, ${includeScreenshots}, page); + await runAxeScan(${needsReviewItems}, ${includeScreenshots}, page, { pageIndex: urlsCrawled.scanned.length + 1, pageImagePath }); } } }; @@ -483,20 +499,12 @@ const clickFunc = async (elem,page, clickOptions=undefined) => { // } else { // appendToGeneratedScript(importStatementsForWin); // } - let multilineStr = ""; - for await (let line of rl) { + for await (let line of rl) { // remove invalid characters var re = /[\uFFFD\uE949\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/g; line = line.replace(re, '').trim(); - - // handle comments - if (line.startsWith("//")) { - appendToGeneratedScript(line); - continue; - } - if (/page\d.close\(\)/.test(line.trim())) { const handleUndefinedPageBlock = `try{ ${line} @@ -526,7 +534,7 @@ const clickFunc = async (elem,page, clickOptions=undefined) => { } } if (!proxy && isHeadless) { - appendToGeneratedScript(`headless: true,`); + appendToGeneratedScript(`headless: true`); continue; } } @@ -564,15 +572,6 @@ const clickFunc = async (elem,page, clickOptions=undefined) => { appendToGeneratedScript(`let elem;`); continue; } - // handle actions which take up multiple lines - multilineStr += line; - if (multilineStr.endsWith(";")) { - line = multilineStr; - multilineStr = ""; - } else { - continue; - } - if (line.trim() === `const page = await context.newPage();`) { if (deviceChosen === 'Mobile') { appendToGeneratedScript(line); @@ -597,8 +596,6 @@ const clickFunc = async (elem,page, clickOptions=undefined) => { } else { appendToGeneratedScript(line); } - // to dismiss file explorer window popups - appendToGeneratedScript("page.on('filechooser', () => {});"); continue; } @@ -660,7 +657,7 @@ const clickFunc = async (elem,page, clickOptions=undefined) => { nextStepNeedsProcessPage = false; } - if (line.trim().includes('.getByRole(') && line.trim().includes('.click(')) { + if (line.trim().includes('.getByRole(') && line.trim().includes('.click()')) { // add includeHidden: true to getByRole options const paramsStartIdx = line.indexOf('getByRole(') + 'getByRole('.length; const paramsEndIdx = line.indexOf(')', paramsStartIdx); @@ -684,76 +681,18 @@ const clickFunc = async (elem,page, clickOptions=undefined) => { } const locator = line.substring(0, lastIndex); appendToGeneratedScript(line); - appendToGeneratedScript(`${locator}.focus();`); - appendToGeneratedScript(`await page.keyboard.up('Shift');`); - continue; - } - - if (line.trim().includes('.setInputFiles(')) { - // handle file uploads - const substituteFilePaths = (code) => { - // default upload directory to be appended to the front of filename - const __dir = path.join(constants.exportDirectory, "Upload Files"); - const regex = /\.setInputFiles\(([^]*)\)/g; - - // to extract string from single quotes (i.e. extract test from 'test') - const getStringWithinSingleQuotes = (str) => { - const pattern = /'([^]*)'/; - // Use the match method to find the substring - const match = str.match(pattern); - return match[1]; - } - - const modifiedCode = code.replace(regex, (match, argument) => { - // contents within the round brackets - let substitutedArgument = argument; - - // Remove array square brackets if any - substitutedArgument = substitutedArgument.replace(/^\[|\]$/g, ''); - - if (substitutedArgument.includes(',')) { - // Argument is an array, split it, prepend __dir to each element, and reassemble the array - const files = substitutedArgument.split(',').map(file => { - const finalFilename = getStringWithinSingleQuotes(file.trim()); - return `"${path.join(__dir, finalFilename)}"` - }); - substitutedArgument = `[${files.join(', ')}]`; - } else { - // Argument is a string, add __dir to it without enclosing it in single quotes - const trimmedArg = substitutedArgument.trim() - const finalFilename = getStringWithinSingleQuotes(trimmedArg); - substitutedArgument = `"${path.join(__dir, finalFilename)}"`; - } - if (os.platform() === 'win32') { - // escape backslashes if on windows - substitutedArgument = substitutedArgument.replaceAll("\\", "\\\\"); - } - return `.setInputFiles(${substitutedArgument})`; - }); - return modifiedCode; - } - appendToGeneratedScript(substituteFilePaths(line)); - nextStepNeedsProcessPage = true; + appendToGeneratedScript(`await ${locator}.focus()`); + appendToGeneratedScript(`await page.keyboard.up('Shift')`); continue; } - const isClick = line.trim().includes('click('); + const isClick = line.trim().includes('click()'); if ((line.trim().includes('getBy') && !line.trim().includes('getByPlaceholder')) || isClick) { const lastIndex = line.lastIndexOf('.'); const locator = line.substring(0, lastIndex); - - if (isClick) { - // get click options if any, include them in the clickFunc - const clickOptions = line.match(/[^]*\.click\(([^]*)\)/)[1]; - appendToGeneratedScript(`elem = ${locator}`); - const clickFuncLine = clickOptions.length - ? `await clickFunc(elem, page, ${clickOptions})` - : 'await clickFunc(elem, page)'; - appendToGeneratedScript(clickFuncLine); - } else { - appendToGeneratedScript(line); - } + isClick && appendToGeneratedScript(`elem = ${locator}`); + appendToGeneratedScript(isClick ? `await clickFunc(elem, page)` : line); nextStepNeedsProcessPage = true; continue; diff --git a/static/ejs/partials/components/pagesScannedModal.ejs b/static/ejs/partials/components/pagesScannedModal.ejs index db12b874..34765b10 100644 --- a/static/ejs/partials/components/pagesScannedModal.ejs +++ b/static/ejs/partials/components/pagesScannedModal.ejs @@ -57,8 +57,18 @@ diff --git a/static/ejs/partials/components/scanAbout.ejs b/static/ejs/partials/components/scanAbout.ejs index 91cf3613..45be56cc 100644 --- a/static/ejs/partials/components/scanAbout.ejs +++ b/static/ejs/partials/components/scanAbout.ejs @@ -171,8 +171,14 @@ scrolling="no" > diff --git a/static/ejs/partials/components/screenshotLightbox.ejs b/static/ejs/partials/components/screenshotLightbox.ejs new file mode 100644 index 00000000..30b56b47 --- /dev/null +++ b/static/ejs/partials/components/screenshotLightbox.ejs @@ -0,0 +1,13 @@ +
+ + +
\ No newline at end of file diff --git a/static/ejs/partials/main.ejs b/static/ejs/partials/main.ejs index 09ea8522..578bd476 100644 --- a/static/ejs/partials/main.ejs +++ b/static/ejs/partials/main.ejs @@ -1,6 +1,7 @@
<%- include("components/scanAbout") %> <%- include("components/pagesScannedModal") %> + <%- include("components/screenshotLightbox") %>
<%- include("components/wcagCompliance") %>
@@ -18,6 +19,9 @@ <%# dynamically generated section from scripts/categorySummary %> <%- include("components/ruleOffcanvas") %> <%- include("footer") %> +

+ Tooltip: Copy +

Tooltip: Contains AI suggestions.

diff --git a/static/ejs/partials/scripts/categorySummary.ejs b/static/ejs/partials/scripts/categorySummary.ejs index 747de68c..f20834d2 100644 --- a/static/ejs/partials/scripts/categorySummary.ejs +++ b/static/ejs/partials/scripts/categorySummary.ejs @@ -38,7 +38,7 @@ scanItems[category].rules.forEach((rule, index) => { const buttonAriaLabel = `${rule.description}, ${rule.totalItems} occurrences`; const purpleAiSvgId = `${category}-${rule.rule}-${index}`; - const isPurpleAiRule = purpleAiRules.includes(rule.rule) && showPurpleAi; + const isPurpleAiRule = purpleAiRules.includes(rule.rule); const ruleItem = createElementFromString(`
  • diff --git a/static/ejs/partials/scripts/ruleOffcanvas.ejs b/static/ejs/partials/scripts/ruleOffcanvas.ejs index ed280b76..3779acc2 100644 --- a/static/ejs/partials/scripts/ruleOffcanvas.ejs +++ b/static/ejs/partials/scripts/ruleOffcanvas.ejs @@ -85,7 +85,7 @@ category summary is clicked %> `), ); - if (purpleAiRules.includes(selectedRule.rule) && showPurpleAi) { + if (purpleAiRules.includes(selectedRule.rule)) { document.querySelector('#expandedRuleAiFeedback').style.display = 'block'; } else { document.querySelector('#expandedRuleAiFeedback').style.display = 'none'; @@ -154,6 +154,7 @@ category summary is clicked %> function buildExpandedRuleCategoryContent(category, ruleInCategory) { const contentContainer = document.getElementById('expandedRuleCategoryContent'); + const isCustomFlow = <%- scanType === 'Customized' -%>; if (category === 'passed') { contentContainer.innerHTML = `You may find the list of passed HTML elements in passed_items.json.txt.`; @@ -179,9 +180,7 @@ category summary is clicked %>
    - ${ - page.url - } + ${ isCustomFlow + ? + ` +
    + + ${page.url} +
    + ` + : `${page.url}` + }
    ${getFormattedCategoryTitle(category)} elements ${page.items.length} @@ -208,6 +214,18 @@ category summary is clicked %>
  • `); + if (isCustomFlow) { + const customScreenshotElem = accordion.getElementsByClassName(`custom-flow-screenshot`)[0]; + customScreenshotElem.onerror = function(event) { + this.onerror = null; + this.remove(); + } + customScreenshotElem.onclick = function(event) { + event.preventDefault(); + openLightbox(this.src, page.pageTitle); + } + } + accordionsList.appendChild(accordion); const accordionBody = accordion.getElementsByClassName('accordion-body')[0]; @@ -218,7 +236,7 @@ category summary is clicked %> const buttonDivForAiFeedback = `${buttonAIId}-${index}`; const aiErrorDiv = `${errorAIId}-${index}`; let itemCard; - const isPurpleAiRule = purpleAiRules.includes(ruleInCategory.rule) && showPurpleAi; + const isPurpleAiRule = purpleAiRules.includes(ruleInCategory.rule); let purpleAiQueryLabel; if (isPurpleAiRule) { purpleAiQueryLabel = await checkPurpleAiQueryLabel(ruleInCategory.rule, item.html); @@ -247,6 +265,40 @@ category summary is clicked %> ` : `` } + ${ item.xpath + ? + ` +
    +
    Path
    +
    +
    + ${item.xpath} + +
    +
    +
    +
    + ` + :`` + }
    ${ item.html @@ -278,13 +330,39 @@ category summary is clicked %>
    AI suggestion
    - ${purpleAiQueryLabel ? ` - -
    ` : ` - Processing AI suggestions, please check back later.` - } + ${purpleAiQueryLabel.hasNetworkError ? + ` +
    ` : + (purpleAiQueryLabel.label ? + ` +
    ` : + ` + Processing AI suggestions, please check back later. + ` + ) + }
    ` : `` } @@ -294,6 +372,35 @@ category summary is clicked %> `); elementCardsList.appendChild(itemCard); + const copyTooltip = itemCard.querySelector('.copy-icon'); + const copyTooltipItem = new bootstrap.Tooltip(copyTooltip); + + const copyButtonElem = itemCard.getElementsByClassName('copy-button')[0]; + const textToCopy = createElementFromString(``) + copyButtonElem.onclick = event => { + textToCopy.select(); + // Copy the text inside the text field + navigator.clipboard.writeText(textToCopy.value); + + copyTooltip.setAttribute('data-bs-original-title', 'Copied'); + copyTooltipItem.update(); + copyTooltipItem.show(); + + setTimeout(() => { + copyTooltip.setAttribute('data-bs-original-title', 'Copy'); + copyTooltipItem.update(); + }, 1500) + }; + copyButtonElem.onmouseover = event => { + copyTooltip.dispatchEvent(new MouseEvent('mouseover')); + }; + copyButtonElem.onmouseout = event => { + copyTooltip.dispatchEvent(new MouseEvent('mouseout')); + } + copyButtonElem.onmousedown = event => { + event.preventDefault(); + } + hljs.configure({ ignoreUnescapedHTML: true }); @@ -433,9 +540,9 @@ category summary is clicked %> 'aria-roles': '

    \n Elements assigned invalid ARIA role values are not interpreted by assistive\n technology as intended by the developer.\n

    ', label: '

    \n Effective form labels are required to make forms accessible. The purpose of\n form elements such as checkboxes, radio buttons, input fields, etcetera, is\n often apparent to sighted users, even if the form element is not\n programmatically labeled. Screen readers users require useful form labels to\n identify form fields. Adding a label to all form elements eliminates ambiguity\n and contributes to a more accessible product.\n

    ', 'landmark-one-main': '

    \n Navigating a web page is far simpler for screen reader users if all of the\n content splits between one or more high-level sections. Content outside of\n these sections is difficult to find, and its purpose may be unclear.\n

    ', - 'aria-braille-equivalent': '

    \nARIA braille attributes were introduced to adjust how labels and role descriptions appear on braille displays, but they cannot be the only attribute providing a label, or a role description. aria-braillelabel must only be applied to elements with an accessible name, like aria-label , and aria-brailleroledescription should only be used with aria-roledescription.\n

    ', - 'aria-conditional-attr': '

    \nARIA attributes should be used according to their specified roles for elements, as using them incorrectly can result in unpredictable behavior for assistive technologies. This can lead to a poor user experience for people with disabilities who rely on these technologies.\n

    ', - 'aria-deprecated-role': '

    \nDeprecated ARIA roles are not recognized or properly processed by screen readers and other assistive technologies. Using these means may hinder some users access to essential information. Always assign values to role that align with current, non-deprecated or abstract ARIA roles.\n

    ', - 'aria-prohibited-attr': '

    \nThis rule specifically checks that none of the attributes used with a particular role are marked as "prohibited" for that role in the latest version of ARIA. For instance, attributes like aria-label and aria-labelledby are not permitted on roles like presentation and none, as well as on text-like roles such as code, insertion, strong and others.\n

    ' + 'aria-braille-equivalent': '

    \nARIA braille attributes were introduced to allow adjusting how labels and role descriptions are rendered on a braille display. They cannot be the only attribute providing a label, or a role description. When used without a corresponding label or role description ARIA says to ignore these attributes, although this may not happen consistently in screen readers and other assistive technologies.\n

    ', + 'aria-conditional-attr': '

    \nUsing ARIA attributes on elements where they are not expected can result in unpredictable behavior for assistive technologies. This can lead to a poor user experience for people with disabilities who rely on these technologies. It is important to follow the ARIA specification to ensure that assistive technologies can properly interpret and communicate the intended meaning of the content.\n

    ', + 'aria-deprecated-role': '

    \nUsing deprecated WAI-ARIA roles is bad for accessibility. They will not be recognized or correctly processed by screen readers and other assistive technologies. Using these means not everyone will be able to access essential information.\n

    ', + 'aria-prohibited-attr': '

    \nUsing ARIA attributes in roles where they are prohibited can mean that important information is not communicated to users of assistive technologies. assistive technologies may also attempt to compensate for the issue, resulting in inconsistent and confusing behavior of these tools.\n

    ' }; \ No newline at end of file diff --git a/static/ejs/partials/scripts/screenshotLightbox.ejs b/static/ejs/partials/scripts/screenshotLightbox.ejs new file mode 100644 index 00000000..30822885 --- /dev/null +++ b/static/ejs/partials/scripts/screenshotLightbox.ejs @@ -0,0 +1,66 @@ +<%# functions used to show lightbox of screenshot when thumbnail is clicked on %> + \ No newline at end of file diff --git a/static/ejs/partials/scripts/utils.ejs b/static/ejs/partials/scripts/utils.ejs index 9345d403..eb51120f 100644 --- a/static/ejs/partials/scripts/utils.ejs +++ b/static/ejs/partials/scripts/utils.ejs @@ -215,7 +215,7 @@ }) .catch((error) => { delete ongoingPromise[key]; // remove the promise from the queue in case of an error - throw error; + throw new Error('Network Error'); }); // add the promise to the queue @@ -232,26 +232,19 @@ getRuleIdData: (ruleId) => `https://govtechsg.github.io/purple-ai/results/${ruleId}.json` } - const hasProxy = `<%=proxy%>`; + const isOffline = () => !window.navigator.onLine; - const checkPurpleAiAvail = async () => { - // check if catalog is empty - try { - return api(apiUrls.catalog).then(catalogData => { - const catalogHasData = Object.keys(catalogData).length > 1; - return catalogHasData; - }) - } catch (e) { - console.error('Unable to fetch Purple AI catalog'); - return false; + const checkPurpleAiQueryLabel = async (ruleId, ruleHtml) => { + const purpleAiQueryLabel = { + label: null, + hasNetworkError: false, + hasGenericError: false } - } - const checkPurpleAiQueryLabel = async (ruleId, ruleHtml) => { return api(apiUrls.catalog).then(catalogData => { // no information for current rule if (!catalogData[ruleId] || catalogData[ruleId].length === 0) { - return null; + return purpleAiQueryLabel; } if (rulesUsingRoles.includes(ruleId)) { @@ -262,7 +255,8 @@ if (roleForHtml) { const currentLabel = `${htmlElement}_${roleForHtml}`; const foundLabel = catalogData[ruleId].find(label => label === currentLabel); - return foundLabel ? escapeHtmlForAI(currentLabel) : null; + purpleAiQueryLabel.label = foundLabel ? escapeHtmlForAI(currentLabel) : null; + return purpleAiQueryLabel; } } @@ -272,7 +266,8 @@ const currentLabel = currentLabelList.join('_'); if (catalogData[ruleId].includes(currentLabel)) { - return escapeHtmlForAI(currentLabel); + purpleAiQueryLabel.label = escapeHtmlForAI(currentLabel); + return purpleAiQueryLabel; } // count the number of elements in keyArr that @@ -286,15 +281,53 @@ return attrMatch.length >= 3; }) - return foundLabel ? escapeHtmlForAI(foundLabel) : null; + purpleAiQueryLabel.label = foundLabel ? escapeHtmlForAI(foundLabel) : null; + return purpleAiQueryLabel; }) .catch(err => { console.error(`An error has occured while checking if ${ruleId} needs AI query`); - return null; + if (err.message === 'Network Error') { + return { + label: null, + hasNetworkError: true, + hasGenericError: false + } + } else { + return { + label: null, + hasNetworkError: false, + hasGenericError: true + } + } }); } - const getPurpleAiAnswer = async (ruleId, accordionDiv, ruleHtmlLabel, buttonsDiv, aiErrorDiv) => { + const handleOfflinePurpleAi = async (ruleId, accordionDiv, html, buttonsDiv, aiErrorDiv) => { + let purpleAiQueryLabel = await checkPurpleAiQueryLabel(ruleId, html); + if (purpleAiQueryLabel.hasNetworkError) { + document + .getElementById(aiErrorDiv) + .replaceChildren( + createElementFromString( + `
    This feature requires internet connection. Please try again
    `, + ), + ); + } else if (purpleAiQueryLabel.hasNetworkError) { + document + .getElementById(aiErrorDiv) + .replaceChildren( + createElementFromString( + `
    Something went wrong. Please try again
    `, + ), + ); + } else if (!purpleAiQueryLabel.label) { + document.getElementById(accordionDiv).innerHTML = `Processing AI suggestions, please check back later.` + } else { + await getPurpleAiAnswer(ruleId, accordionDiv, purpleAiQueryLabel.label, buttonsDiv, aiErrorDiv); + } + } + + const getPurpleAiAnswer = async (ruleId, accordionDiv, ruleHtmlLabel, buttonsDiv, aiErrorDiv, html) => { const storagePath = '<%= storagePath %>'; document.getElementById(buttonsDiv).disabled = true; document.getElementById(buttonsDiv).textContent = 'Generating...'; @@ -307,62 +340,67 @@ `$1`, ); const replacedRuleHtmlLabel = escapeHtmlForAI(ruleHtmlLabel); - const aiVoteFeedback = JSON.parse(localStorage.getItem(storagePath)); - if (aiVoteFeedback && aiVoteFeedback[buttonsDiv]) { - var voteString = aiVoteFeedback[buttonsDiv] === 'useful' ? - `You rated this AI suggestion useful.` : - `You rated this AI suggestion not useful`; - document.getElementById(accordionDiv).innerHTML = `
    + document.getElementById(accordionDiv).innerHTML = ` +

    ${replacedString.replace(/\n/g, '
    ')}

    -
    -

    ${voteString}Undo -

    -
    -
    `; - } else { - document.getElementById(accordionDiv).innerHTML = `
    -

    - ${replacedString.replace(/\n/g, '
    ')} -

    - -
    - `; +
    ` + + if (!isOffline()) { + const aiVoteFeedback = JSON.parse(localStorage.getItem(storagePath)); + if (aiVoteFeedback && aiVoteFeedback[buttonsDiv]) { + var voteString = aiVoteFeedback[buttonsDiv] === 'useful' ? + `You rated this AI suggestion useful.` : + `You rated this AI suggestion not useful`; + const votedElem = createElementFromString(` +
    +

    ${voteString}Undo +

    +
    + `) + document.getElementById(accordionDiv).getElementsByClassName('ai-response-card')[0].appendChild(votedElem); + } else { + const feedbackButtonsElem = createElementFromString( ` + + `); + document.getElementById(accordionDiv).getElementsByClassName('ai-response-card')[0].appendChild(feedbackButtonsElem); + } } + document.querySelectorAll('.codeForAiResponse').forEach(el => { hljs.highlightElement(el); }); @@ -371,11 +409,14 @@ document.getElementById(buttonsDiv).disabled = false; document.getElementById(buttonsDiv).style = `border: 1px solid #6E52EF;color: #6E52EF;`; document.getElementById(buttonsDiv).textContent = 'Generate response'; + const errorMessageToDisplay = err.message === 'Network Error' + ? 'This feature requires internet connection. Please try again' + : 'Something went wrong. Please try again.' document .getElementById(aiErrorDiv) .replaceChildren( createElementFromString( - `
    Something went wrong. Please try again.
    `, + `
    ${errorMessageToDisplay}
    `, ), ); }); @@ -391,6 +432,12 @@ return titles[category]; } + function escapeHtmlStringForArg(string) { + return htmlEscapeString(string) + .replaceAll(''', "\\'") + .replace(/>\s+(.*?)\s+<\//g, '>$1</'); + } + function htmlEscapeString(string) { if (string.includes('</script>')) { string = string.replaceAll('</script>', '<\/script>') diff --git a/static/ejs/partials/styles/styles.ejs b/static/ejs/partials/styles/styles.ejs index 4acb23ee..dc49bf18 100644 --- a/static/ejs/partials/styles/styles.ejs +++ b/static/ejs/partials/styles/styles.ejs @@ -352,6 +352,56 @@ display: inline-block; } + .screenshot-lightbox { + display: none; + position: fixed; + z-index: 9999; + padding: 32px; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.7); + } + .lightbox-content { + position: relative; + margin: auto; + display: block; + height: 100%; + width: 80%; + overflow: auto; + } + .lightbox-header { + display: flex; + flex-direction: row; + width: 100%; + align-items: center; + margin: 1rem 0; + } + .lightbox-header button { + background-color: rgba(0, 0, 0, 0.8); + border: none; + border-radius: 50%; + margin: 0; + min-height: 36px; + min-width: 36px; + } + .lightbox-header h5 { + flex: 1; + text-align: center; + color: #FFFFFF; + font-weight: 700; + } + #lightbox-image { + object-fit: contain; + position: absolute; + width: 100%; + } + #lightbox-image:hover { + cursor: zoom-in; + } + #pagesScannedModalToggle { background-color: transparent; color: #0047FA; @@ -421,17 +471,32 @@ #pagesScannedModal .not-scanned-url { word-wrap: break-word; } - #pagesScannedModal span { + #pagesScannedModal p { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #4A6168; + margin: 0; } #pagesScannedModal h6 { padding-left: 0.5rem; } + + #pagesScannedModal .custom-flow-screenshot-container { + flex-direction: row; + } + + #pagesScannedModal .custom-flow-screenshot-container img { + height: 56px; + min-width: 20%; + } + + #pagesScannedModal .display-url-container { + min-width: 80%;; + } + #wcag-compliance-card, #top-five-card { border-radius: 0.25rem; @@ -965,6 +1030,23 @@ list-style-position: outside; } + .page-item-card-section-content .copy-button { + align-self: center; + all: unset; + border: 1px solid #97ADB5; + border-radius: 4px; + cursor: pointer; + margin-left: 0.5rem; + padding: 0 0.25rem; + } + + .page-item-card-section-content .path-container { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; + } + .processAI { color: #4a6168; } @@ -973,6 +1055,21 @@ gap: 1rem; } + .custom-flow-screenshot { + border: 1px solid #2E69FF; + border-radius: 4px; + width: 150px; + height: 128px; + object-fit: cover; + object-position: 100% 0; + } + + .custom-flow-screenshot-container { + display: flex; + align-items: top; + gap: 1rem + } + #expandedRuleCategoryContent .accordion-body pre, .bg-grey-w-border { background-color: #f6f8f9; border: 1px solid #b5c5ca; diff --git a/static/ejs/report.ejs b/static/ejs/report.ejs index 831e41ca..ffa6a69e 100644 --- a/static/ejs/report.ejs +++ b/static/ejs/report.ejs @@ -20,6 +20,7 @@ <%- include('partials/scripts/utils') %> <%- include('partials/scripts/categorySummary') %> <%- include('partials/scripts/ruleOffcanvas') %> + <%- include('partials/scripts/screenshotLightbox') %> - \ No newline at end of file