diff --git a/src/cli.ts b/src/cli.ts index 00219933..85ec7216 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,8 @@ import printMessage from 'print-message'; import { devices } from 'playwright'; import { fileURLToPath } from 'url'; import path from 'path'; -import { setHeadlessMode, getVersion, getStoragePath, listenForCleanUp, cleanUpAndExit } from './utils.js'; +import { setHeadlessMode, listenForCleanUp, cleanUpAndExit } from './utils.js'; +import { getStoragePath, getVersion } from './utils/index.js'; import { checkUrl, prepareData, diff --git a/src/combine.ts b/src/combine.ts index 1ff7958d..36a55f3e 100644 --- a/src/combine.ts +++ b/src/combine.ts @@ -5,7 +5,8 @@ import crawlDomain from './crawlers/crawlDomain.js'; import crawlLocalFile from './crawlers/crawlLocalFile.js'; import crawlIntelligentSitemap from './crawlers/crawlIntelligentSitemap.js'; import generateArtifacts from './mergeAxeResults.js'; -import { getHost, createAndUpdateResultsFolders, cleanUpAndExit, getStoragePath } from './utils.js'; +import { createAndUpdateResultsFolders, cleanUpAndExit } from './utils.js'; +import { getHost, getStoragePath } from './utils/index.js'; import { ScannerTypes, UrlsCrawled } from './constants/constants.js'; import { getBlackListedPatterns, submitForm } from './constants/common.js'; import { consoleLogger, silentLogger } from './logs.js'; @@ -328,4 +329,4 @@ const combineRun = async (details: Data, deviceToScan: string) => { } }; -export default combineRun; \ No newline at end of file +export default combineRun; diff --git a/src/crawlers/commonCrawlerFunc.ts b/src/crawlers/commonCrawlerFunc.ts index c93780b5..dd32abc8 100644 --- a/src/crawlers/commonCrawlerFunc.ts +++ b/src/crawlers/commonCrawlerFunc.ts @@ -22,7 +22,7 @@ import { flagUnlabelledClickableElements } from './custom/flagUnlabelledClickabl import xPathToCss from './custom/xPathToCss.js'; import type { Response as PlaywrightResponse } from 'playwright'; import fs from 'fs'; -import { getStoragePath } from '../utils.js'; +import { getStoragePath } from '../utils/index.js'; import path from 'path'; // types diff --git a/src/crawlers/crawlLocalFile.ts b/src/crawlers/crawlLocalFile.ts index b00e7f5b..cc839f22 100644 --- a/src/crawlers/crawlLocalFile.ts +++ b/src/crawlers/crawlLocalFile.ts @@ -19,7 +19,8 @@ import { import { runPdfScan, mapPdfScanResults, doPdfScreenshots } from './pdfScanFunc.js'; import { guiInfoLog } from '../logs.js'; import crawlSitemap from './crawlSitemap.js'; -import { getPdfStoragePath, getStoragePath, register } from '../utils.js'; +import { register } from '../utils.js'; +import { getPdfStoragePath } from '../utils/index.js'; export const crawlLocalFile = async ({ url, diff --git a/src/crawlers/crawlSitemap.ts b/src/crawlers/crawlSitemap.ts index 3f5c3fae..19ccef8f 100644 --- a/src/crawlers/crawlSitemap.ts +++ b/src/crawlers/crawlSitemap.ts @@ -23,7 +23,8 @@ import { waitForPageLoaded, isFilePath, } from '../constants/common.js'; -import { areLinksEqual, isWhitelistedContentType, register } from '../utils.js'; +import { areLinksEqual, register } from '../utils.js'; +import { isWhitelistedContentType } from '../utils/index.js'; import { handlePdfDownload, runPdfScan, @@ -447,4 +448,4 @@ const crawlSitemap = async ({ return { urlsCrawled, durationExceeded }; }; -export default crawlSitemap; \ No newline at end of file +export default crawlSitemap; diff --git a/src/crawlers/pdfScanFunc.ts b/src/crawlers/pdfScanFunc.ts index 66fe86b8..47861984 100644 --- a/src/crawlers/pdfScanFunc.ts +++ b/src/crawlers/pdfScanFunc.ts @@ -15,7 +15,8 @@ import constants, { STATUS_CODE_METADATA, UrlsCrawled, } from '../constants/constants.js'; -import { cleanUpAndExit, getPdfStoragePath, getStoragePath } from '../utils.js'; +import { cleanUpAndExit } from '../utils.js'; +import { getPdfStoragePath, getStoragePath } from '../utils/index.js'; import { error } from 'console'; const require = createRequire(import.meta.url); diff --git a/src/index.ts b/src/index.ts index 76529731..bfbacdea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,12 @@ import printMessage from 'print-message'; import inquirer from 'inquirer'; import { EnqueueStrategy } from 'crawlee'; import { - getVersion, getUserDataTxt, writeToUserDataTxt, listenForCleanUp, cleanUpAndExit, } from './utils.js'; +import { getVersion } from './utils/index.js'; import { prepareData, messageOptions, diff --git a/src/mergeAxeResults.ts b/src/mergeAxeResults.ts index d7575898..108be57b 100644 --- a/src/mergeAxeResults.ts +++ b/src/mergeAxeResults.ts @@ -27,8 +27,6 @@ import { getBrowserToRun, getPlaywrightLaunchOptions } from './constants/common. import { createScreenshotsFolder, - getStoragePath, - getVersion, getWcagPassPercentage, getProgressPercentage, retryFunction, @@ -39,6 +37,7 @@ import { getUserDataTxt, register, } from './utils.js'; +import { getStoragePath, getVersion } from './utils/index.js'; import { consoleLogger, silentLogger } from './logs.js'; import itemTypeDescription from './constants/itemTypeDescription.js'; import { oobeeAiHtmlETL, oobeeAiRules } from './constants/oobeeAi.js'; @@ -2311,4 +2310,4 @@ export { formatAboutStartTime, }; -export default generateArtifacts; \ No newline at end of file +export default generateArtifacts; diff --git a/src/npmIndex.ts b/src/npmIndex.ts index 44a113ca..da5ffffa 100644 --- a/src/npmIndex.ts +++ b/src/npmIndex.ts @@ -13,7 +13,8 @@ import { submitForm, } from './constants/common.js'; import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js'; -import { createAndUpdateResultsFolders, getVersion } from './utils.js'; +import { createAndUpdateResultsFolders } from './utils.js'; +import { getVersion } from './utils/index.js'; import generateArtifacts, { createBasicFormHTMLSnippet, sendWcagBreakdownToSentry } from './mergeAxeResults.js'; import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js'; import { consoleLogger, silentLogger } from './logs.js'; @@ -878,4 +879,3 @@ export const scanPage = async ( }; export { RuleFlags }; - diff --git a/src/utils.ts b/src/utils.ts index dbd0447b..990ea6b8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,96 +1,17 @@ -import { execSync, spawnSync } from 'child_process'; import path from 'path'; import os from 'os'; import fs from 'fs-extra'; import axe, { Rule } from 'axe-core'; import { v4 as uuidv4 } from 'uuid'; +import JSZip from 'jszip'; +import { createReadStream, createWriteStream } from 'fs'; import constants, { - BrowserTypes, destinationPath, getIntermediateScreenshotsPath, } from './constants/constants.js'; -import { consoleLogger, errorsTxtPath, silentLogger } from './logs.js'; +import { consoleLogger, errorsTxtPath } from './logs.js'; import { getAxeConfiguration } from './crawlers/custom/getAxeConfiguration.js'; -import JSZip from 'jszip'; -import { createReadStream, createWriteStream } from 'fs'; - -export const getVersion = () => { - const loadJSON = (filePath: string): { version: string } => - JSON.parse(fs.readFileSync(new URL(filePath, import.meta.url)).toString()); - const versionNum = loadJSON('../package.json').version; - - return versionNum; -}; - -export const getHost = (url: string): string => new URL(url).host; - -export const getCurrentDate = () => { - const date = new Date(); - return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; -}; - -export const isWhitelistedContentType = (contentType: string): boolean => { - const whitelist = ['text/html']; - return whitelist.filter(type => contentType.trim().startsWith(type)).length === 1; -}; - -export const getPdfStoragePath = (randomToken: string): string => { - const storagePath = getStoragePath(randomToken); - const pdfStoragePath = path.join(storagePath, 'pdfs'); - if (!fs.existsSync(pdfStoragePath)) { - fs.mkdirSync(pdfStoragePath, { recursive: true }); - } - return pdfStoragePath; -}; - -export const getStoragePath = (randomToken: string): string => { - // If exportDirectory is set, use it - if (constants.exportDirectory) { - return constants.exportDirectory; - } - - // Otherwise, use the current working directory - let storagePath = path.join(process.cwd(), 'results', randomToken); - - // Ensure storagePath is writable; if directory doesn't exist, try to create it in Documents or home directory - const isWritable = (() => { - try { - if (!fs.existsSync(storagePath)) { - fs.mkdirSync(storagePath, { recursive: true }); - } - fs.accessSync(storagePath, fs.constants.W_OK); - return true; - } catch { - return false; - } - })(); - - if (!isWritable) { - if (os.platform() === 'win32') { - // Use Documents folder on Windows - const documentsPath = path.join(process.env.USERPROFILE || process.env.HOMEPATH || '', 'Documents'); - storagePath = path.join(documentsPath, 'Oobee', randomToken); - } else if (os.platform() === 'darwin') { - // Use Documents folder on Mac - const documentsPath = path.join(process.env.HOME || '', 'Documents'); - storagePath = path.join(documentsPath, 'Oobee', randomToken); - } else { - // Use home directory for Linux/other - const homePath = process.env.HOME || ''; - storagePath = path.join(homePath, 'Oobee', randomToken); - } - consoleLogger.warn(`Warning: Cannot write to cwd, writing to ${storagePath}`); - - } - - if (!fs.existsSync(storagePath)) { - fs.mkdirSync(storagePath, { recursive: true }); - } - - constants.exportDirectory = storagePath; - return storagePath; - -}; +import { getStoragePath } from './utils/index.js'; export const getUserDataFilePath = () => { const platform = os.platform(); @@ -204,8 +125,6 @@ export const createScreenshotsFolder = (randomToken: string): void => { }); } }; - - let __shuttingDown = false; let __stopAllLock: Promise | null = null; let __softCloseHandler: (() => Promise) | null = null; @@ -216,7 +135,9 @@ export function registerSoftClose(handler: () => Promise) { export async function softCloseBrowserAndContext() { if (!__softCloseHandler) { - consoleLogger.info('softCloseBrowserAndContext: no handler registered (probably not a custom-flow scan)'); + consoleLogger.info( + 'softCloseBrowserAndContext: no handler registered (probably not a custom-flow scan)', + ); return; } @@ -273,10 +194,8 @@ export async function stopAll({ mode = 'graceful', timeoutMs = 10_000 } = {}) { } } else if (mode === 'abort') { pool?.abort?.(); - } else { - if (typeof c.teardown === 'function') { - await Promise.race([c.teardown(), timeout(timeoutMs)]); - } + } else if (typeof c.teardown === 'function') { + await Promise.race([c.teardown(), timeout(timeoutMs)]); } consoleLogger.info(`Crawler closed (${mode})`); } catch (err) { @@ -301,7 +220,9 @@ export async function stopAll({ mode = 'graceful', timeoutMs = 10_000 } = {}) { // ➜ Graceful: don't kill contexts that are still doing work if (mode === 'graceful' && hasOpenPages) { - consoleLogger.info(`Skipping BrowserContext in graceful (has ${pagesArr.length} open page(s))`); + consoleLogger.info( + `Skipping BrowserContext in graceful (has ${pagesArr.length} open page(s))`, + ); continue; // leave it for the teardown pass } @@ -309,7 +230,9 @@ export async function stopAll({ mode = 'graceful', timeoutMs = 10_000 } = {}) { if (hasOpenPages) { consoleLogger.info(`Closing ${pagesArr.length} page(s) before context close...`); for (const p of pagesArr) { - try { await Promise.race([p.close(), timeout(1500)]); } catch {} + try { + await Promise.race([p.close(), timeout(1500)]); + } catch {} } } @@ -369,21 +292,21 @@ export async function stopAll({ mode = 'graceful', timeoutMs = 10_000 } = {}) { } export const cleanUp = async (randomToken?: string, isError: boolean = false): Promise => { - if (isError) { await stopAll({ mode: 'graceful', timeoutMs: 8000 }); await stopAll({ mode: 'teardown', timeoutMs: 4000 }); } - + if (randomToken === undefined && constants.randomToken) { randomToken = constants.randomToken; } - if (constants.userDataDirectory) try { - fs.rmSync(constants.userDataDirectory, { recursive: true, force: true }); - } catch (error) { - consoleLogger.warn(`Unable to force remove userDataDirectory: ${error.message}`); - } + if (constants.userDataDirectory) + try { + fs.rmSync(constants.userDataDirectory, { recursive: true, force: true }); + } catch (error) { + consoleLogger.warn(`Unable to force remove userDataDirectory: ${error.message}`); + } if (randomToken !== undefined) { const storagePath = getStoragePath(randomToken); @@ -399,7 +322,7 @@ export const cleanUp = async (randomToken?: string, isError: boolean = false): P } catch (error) { consoleLogger.warn(`Unable to force remove pdfs folder: ${error.message}`); } - + let deleteErrorLogFile = true; if (isError) { @@ -414,13 +337,12 @@ export const cleanUp = async (randomToken?: string, isError: boolean = false): P const logFilePath = path.join(logsPath, `logs-${randomToken}.txt`); fs.copyFileSync(errorsTxtPath, logFilePath); console.log(`An error occured. Log file is located at: ${logFilePath}`); - } catch (copyError) { consoleLogger.error(`Error copying errors file during cleanup: ${copyError.message}`); console.log(`An error occured. Log file is located at: ${errorsTxtPath}`); deleteErrorLogFile = false; // Do not delete the log file if copy failed } - + if (deleteErrorLogFile && fs.existsSync(errorsTxtPath)) { try { fs.unlinkSync(errorsTxtPath); @@ -428,24 +350,20 @@ export const cleanUp = async (randomToken?: string, isError: boolean = false): P consoleLogger.warn(`Unable to delete log file ${errorsTxtPath}: ${error.message}`); } } - } + } - } - if (fs.existsSync(storagePath) && fs.readdirSync(storagePath).length === 0) { try { fs.rmdirSync(storagePath); consoleLogger.info(`Deleted empty storage path: ${storagePath}`); - } catch (error) { consoleLogger.warn(`Error deleting empty storage path ${storagePath}: ${error.message}`); } } consoleLogger.info(`Clean up completed for: ${randomToken}`); - } - + } }; export const cleanUpAndExit = async ( @@ -460,7 +378,7 @@ export const cleanUpAndExit = async ( __shuttingDown = true; try { - await cleanUp(randomToken, isError); // runs stopAll inside cleanUp + await cleanUp(randomToken, isError); // runs stopAll inside cleanUp } catch (e: any) { consoleLogger.warn(`Cleanup error: ${e?.message || e}`); } @@ -475,13 +393,15 @@ export const listenForCleanUp = (randomToken: string): void => { consoleLogger.info(`PID: ${process.pid}`); // SIGINT signal happens when the user presses Ctrl+C in the terminal - process.on('SIGINT', async () => { // ← keep handler installed + process.on('SIGINT', async () => { + // ← keep handler installed consoleLogger.info('SIGINT received. Cleaning up and exiting.'); await cleanUpAndExit(130, randomToken, true); }); // SIGTERM signal happens when the process is terminated (by another process or system shutdown) - process.on('SIGTERM', async () => { // ← keep handler installed + process.on('SIGTERM', async () => { + // ← keep handler installed consoleLogger.info('SIGTERM received. Cleaning up and exiting.'); await cleanUpAndExit(143, randomToken, true); }); @@ -506,7 +426,14 @@ export const getWcagPassPercentage = ( totalWcagViolationsAAandAAA: number; } => { // These AAA rules should not be counted as WCAG Pass Percentage only contains A and AA - const wcagAAALinks = ['WCAG 1.4.6', 'WCAG 2.2.4', 'WCAG 2.4.9', 'WCAG 3.1.5', 'WCAG 3.2.5', 'WCAG 2.1.3']; + const wcagAAALinks = [ + 'WCAG 1.4.6', + 'WCAG 2.2.4', + 'WCAG 2.4.9', + 'WCAG 3.1.5', + 'WCAG 3.2.5', + 'WCAG 2.1.3', + ]; const wcagAAA = ['wcag146', 'wcag224', 'wcag249', 'wcag315', 'wcag325', 'wcag213']; const wcagLinksAAandAAA = constants.wcagLinks; @@ -734,7 +661,7 @@ export const getTotalRulesCount = async ( */ export const getWcagCriteriaMap = async ( enableWcagAaa: boolean = true, - disableOobee: boolean = false + disableOobee: boolean = false, ): Promise> => { // Reuse the configuration setup from getTotalRulesCount const axeConfig = getAxeConfiguration({ @@ -785,42 +712,43 @@ export const getWcagCriteriaMap = async ( // Build WCAG criteria map const wcagCriteriaMap: Record = {}; - + // Process rules to extract WCAG information mergedRules.forEach(rule => { if (!rule.enabled) return; if (rule.id === 'frame-tested') return; - + const tags = rule.tags || []; if (tags.includes('experimental') || tags.includes('deprecated')) return; - + // Look for WCAG criteria tags (format: wcag111, wcag143, etc.) tags.forEach(tag => { const wcagMatch = tag.match(/^wcag(\d+)$/); if (wcagMatch) { const wcagId = tag; - + // Default values let level = 'a'; let name = ''; - + // Try to extract better info from metadata if available const metadata = rule.metadata as any; if (metadata && metadata.wcag) { const wcagInfo = metadata.wcag as any; - + // Find matching criterion in metadata for (const key in wcagInfo) { const criterion = wcagInfo[key]; - if (criterion && - criterion.num && - `wcag${criterion.num.replace(/\./g, '')}` === wcagId) { - + if ( + criterion && + criterion.num && + `wcag${criterion.num.replace(/\./g, '')}` === wcagId + ) { // Extract level if (criterion.level) { level = String(criterion.level).toLowerCase(); } - + // Extract name if (criterion.handle) { name = String(criterion.handle); @@ -829,28 +757,28 @@ export const getWcagCriteriaMap = async ( } else if (criterion.num) { name = `wcag-${String(criterion.num).replace(/\./g, '-')}`; } - + break; } } } - + // Generate fallback name if none found if (!name) { const numStr = wcagMatch[1]; const formattedNum = numStr.replace(/(\d)(\d)(\d+)?/, '$1.$2.$3'); name = `wcag-${formattedNum.replace(/\./g, '-')}`; } - + // Store in map - wcagCriteriaMap[wcagId] = { + wcagCriteriaMap[wcagId] = { name: name.toLowerCase().replace(/_/g, '-'), - level + level, }; } }); }); - + return wcagCriteriaMap; }; @@ -1009,7 +937,11 @@ export const zipResults = async (zipName: string, resultsPath: string): Promise< fs.mkdirSync(path.dirname(zipFilePath), { recursive: true }); // Remove any prior file atomically - try { fs.unlinkSync(zipFilePath); } catch { /* ignore if not exists */ } + try { + fs.unlinkSync(zipFilePath); + } catch { + /* ignore if not exists */ + } // CWD must exist and be a directory const stats = fs.statSync(resultsPath); @@ -1042,7 +974,8 @@ export const zipResults = async (zipName: string, resultsPath: string): Promise< await new Promise((resolve, reject) => { const outStream = createWriteStream(zipFilePath); - zipStream.pipe(outStream) + zipStream + .pipe(outStream) .on('finish', () => resolve(undefined)) .on('error', reject); }); @@ -1097,7 +1030,7 @@ export const retryFunction = async (func: () => Promise, maxAttempt: numbe const result = await func(); return result; } catch (error) { - // do nothing, just retry + // do nothing, just retry } } throw new Error('Maximum number of attempts reached'); @@ -1106,17 +1039,17 @@ export const retryFunction = async (func: () => Promise, maxAttempt: numbe /** * Determines which WCAG criteria might appear in the "needsReview" category * based on axe-core's rule configuration. - * + * * This dynamically analyzes the rules that might produce "incomplete" results which * get categorized as "needsReview" during scans. - * + * * @param enableWcagAaa Whether to include WCAG AAA criteria * @param disableOobee Whether to disable custom Oobee rules * @returns A map of WCAG criteria IDs to whether they may produce needsReview results */ export const getPotentialNeedsReviewWcagCriteria = async ( enableWcagAaa: boolean = true, - disableOobee: boolean = false + disableOobee: boolean = false, ): Promise> => { // Reuse configuration setup from other functions const axeConfig = getAxeConfiguration({ @@ -1127,68 +1060,66 @@ export const getPotentialNeedsReviewWcagCriteria = async ( // Configure axe-core with our settings axe.configure(axeConfig); - + // Get all rules from axe-core const allRules = axe.getRules(); - + // Set to store rule IDs that might produce incomplete results const rulesLikelyToProduceIncomplete = new Set(); - + // Dynamically analyze each rule and its checks to determine if it might produce incomplete results for (const rule of allRules) { try { // Skip disabled rules const customRule = axeConfig.rules.find(r => r.id === rule.ruleId); if (customRule && customRule.enabled === false) continue; - + // Skip frame-tested rule as it's handled specially if (rule.ruleId === 'frame-tested') continue; - + // Get the rule object from axe-core's internal data const ruleObj = (axe as any)._audit?.rules?.find(r => r.id === rule.ruleId); if (!ruleObj) continue; - + // For each check in the rule, determine if it might produce an "incomplete" result - const checks = [ - ...(ruleObj.any || []), - ...(ruleObj.all || []), - ...(ruleObj.none || []) - ]; - + const checks = [...(ruleObj.any || []), ...(ruleObj.all || []), ...(ruleObj.none || [])]; + // Get check details from axe-core's internal data for (const checkId of checks) { const check = (axe as any)._audit?.checks?.[checkId]; if (!check) continue; - + // A check can produce incomplete results if: // 1. It has an "incomplete" message // 2. Its evaluate function explicitly returns undefined // 3. It is known to need human verification (accessibility issues that are context-dependent) const hasIncompleteMessage = check.messages && 'incomplete' in check.messages; - + // Many checks are implemented as strings that are later evaluated to functions const evaluateCode = check.evaluate ? check.evaluate.toString() : ''; - const explicitlyReturnsUndefined = evaluateCode.includes('return undefined') || - evaluateCode.includes('return;'); - + const explicitlyReturnsUndefined = + evaluateCode.includes('return undefined') || evaluateCode.includes('return;'); + // Some checks use specific patterns that indicate potential for incomplete results - const indicatesManualVerification = + const indicatesManualVerification = evaluateCode.includes('return undefined') || evaluateCode.includes('this.data(') || evaluateCode.includes('options.reviewOnFail') || evaluateCode.includes('incomplete') || (check.metadata && check.metadata.incomplete === true); - + if (hasIncompleteMessage || explicitlyReturnsUndefined || indicatesManualVerification) { rulesLikelyToProduceIncomplete.add(rule.ruleId); break; // One check is enough to mark the rule } } - + // Also check rule-level metadata for indicators of potential incomplete results if (ruleObj.metadata) { - if (ruleObj.metadata.incomplete === true || - (ruleObj.metadata.messages && 'incomplete' in ruleObj.metadata.messages)) { + if ( + ruleObj.metadata.incomplete === true || + (ruleObj.metadata.messages && 'incomplete' in ruleObj.metadata.messages) + ) { rulesLikelyToProduceIncomplete.add(rule.ruleId); } } @@ -1197,20 +1128,20 @@ export const getPotentialNeedsReviewWcagCriteria = async ( // This is a safeguard against unexpected changes in axe-core's internal structure } } - + // Also check custom Oobee rules if they're enabled if (!disableOobee) { for (const rule of axeConfig.rules || []) { if (!rule.enabled) continue; - + // Check if the rule's metadata indicates it might produce incomplete results try { - const hasIncompleteMessage = - ((rule as any)?.metadata?.messages?.incomplete !== undefined) || - (axeConfig.checks || []).some(check => - check.id === rule.id && - (check.metadata?.messages?.incomplete !== undefined)); - + const hasIncompleteMessage = + (rule as any)?.metadata?.messages?.incomplete !== undefined || + (axeConfig.checks || []).some( + check => check.id === rule.id && check.metadata?.messages?.incomplete !== undefined, + ); + if (hasIncompleteMessage) { rulesLikelyToProduceIncomplete.add(rule.id); } @@ -1219,22 +1150,22 @@ export const getPotentialNeedsReviewWcagCriteria = async ( } } } - + // Map from WCAG criteria IDs to whether they might produce needsReview results const potentialNeedsReviewCriteria: Record = {}; - + // Process each rule to map to WCAG criteria for (const rule of allRules) { if (rule.ruleId === 'frame-tested') continue; - + const tags = rule.tags || []; if (tags.includes('experimental') || tags.includes('deprecated')) continue; - + // Map rule to WCAG criteria for (const tag of tags) { if (/^wcag\d+$/.test(tag)) { const mightNeedReview = rulesLikelyToProduceIncomplete.has(rule.ruleId); - + // If we haven't seen this criterion before or we're updating it to true if (mightNeedReview || !potentialNeedsReviewCriteria[tag]) { potentialNeedsReviewCriteria[tag] = mightNeedReview; @@ -1242,14 +1173,14 @@ export const getPotentialNeedsReviewWcagCriteria = async ( } } } - + return potentialNeedsReviewCriteria; }; /** * Categorizes a WCAG criterion into one of: "mustFix", "goodToFix", or "needsReview" * for use in Sentry reporting - * + * * @param wcagId The WCAG criterion ID (e.g., "wcag144") * @param enableWcagAaa Whether WCAG AAA criteria are enabled * @param disableOobee Whether Oobee custom rules are disabled @@ -1258,34 +1189,33 @@ export const getPotentialNeedsReviewWcagCriteria = async ( export const categorizeWcagCriterion = async ( wcagId: string, enableWcagAaa: boolean = true, - disableOobee: boolean = false + disableOobee: boolean = false, ): Promise<'mustFix' | 'goodToFix' | 'needsReview'> => { // First check if this criterion might produce "needsReview" results const needsReviewMap = await getPotentialNeedsReviewWcagCriteria(enableWcagAaa, disableOobee); if (needsReviewMap[wcagId]) { return 'needsReview'; } - + // Get the WCAG criteria map to check the level const wcagCriteriaMap = await getWcagCriteriaMap(enableWcagAaa, disableOobee); const criterionInfo = wcagCriteriaMap[wcagId]; - + if (!criterionInfo) { // If we can't find info, default to mustFix for safety return 'mustFix'; } - + // Check if it's a level A or AA criterion (mustFix) or AAA (goodToFix) if (criterionInfo.level === 'a' || criterionInfo.level === 'aa') { return 'mustFix'; - } else { - return 'goodToFix'; } + return 'goodToFix'; }; /** * Batch categorizes multiple WCAG criteria for Sentry reporting - * + * * @param wcagIds Array of WCAG criterion IDs (e.g., ["wcag144", "wcag143"]) * @param enableWcagAaa Whether WCAG AAA criteria are enabled * @param disableOobee Whether Oobee custom rules are disabled @@ -1294,32 +1224,32 @@ export const categorizeWcagCriterion = async ( export const categorizeWcagCriteria = async ( wcagIds: string[], enableWcagAaa: boolean = true, - disableOobee: boolean = false + disableOobee: boolean = false, ): Promise> => { // Get both maps once to avoid repeated expensive calls const [needsReviewMap, wcagCriteriaMap] = await Promise.all([ getPotentialNeedsReviewWcagCriteria(enableWcagAaa, disableOobee), - getWcagCriteriaMap(enableWcagAaa, disableOobee) + getWcagCriteriaMap(enableWcagAaa, disableOobee), ]); - + const result: Record = {}; - + wcagIds.forEach(wcagId => { // First check if this criterion might produce "needsReview" results if (needsReviewMap[wcagId]) { result[wcagId] = 'needsReview'; return; } - + // Get criterion info const criterionInfo = wcagCriteriaMap[wcagId]; - + if (!criterionInfo) { // If we can't find info, default to mustFix for safety result[wcagId] = 'mustFix'; return; } - + // Check if it's a level A or AA criterion (mustFix) or AAA (goodToFix) if (criterionInfo.level === 'a' || criterionInfo.level === 'aa') { result[wcagId] = 'mustFix'; @@ -1327,6 +1257,6 @@ export const categorizeWcagCriteria = async ( result[wcagId] = 'goodToFix'; } }); - + return result; }; diff --git a/src/utils/getCurrentDate.ts b/src/utils/getCurrentDate.ts new file mode 100644 index 00000000..c2b9c21a --- /dev/null +++ b/src/utils/getCurrentDate.ts @@ -0,0 +1,6 @@ +const getCurrentDate = (): string => { + const date = new Date(); + return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; +}; + +export default getCurrentDate; diff --git a/src/utils/getHost.ts b/src/utils/getHost.ts new file mode 100644 index 00000000..80f33415 --- /dev/null +++ b/src/utils/getHost.ts @@ -0,0 +1,3 @@ +const getHost = (url: string): string => new URL(url).host; + +export default getHost; diff --git a/src/utils/getPdfStoragePath.ts b/src/utils/getPdfStoragePath.ts new file mode 100644 index 00000000..e1130e32 --- /dev/null +++ b/src/utils/getPdfStoragePath.ts @@ -0,0 +1,14 @@ +import path from 'path'; +import fs from 'fs-extra'; +import getStoragePath from './getStoragePath.js'; + +const getPdfStoragePath = (randomToken: string): string => { + const storagePath = getStoragePath(randomToken); + const pdfStoragePath = path.join(storagePath, 'pdfs'); + if (!fs.existsSync(pdfStoragePath)) { + fs.mkdirSync(pdfStoragePath, { recursive: true }); + } + return pdfStoragePath; +}; + +export default getPdfStoragePath; diff --git a/src/utils/getStoragePath.ts b/src/utils/getStoragePath.ts new file mode 100644 index 00000000..da955147 --- /dev/null +++ b/src/utils/getStoragePath.ts @@ -0,0 +1,57 @@ +import path from 'path'; +import os from 'os'; +import fs from 'fs-extra'; +import constants from '../constants/constants.js'; +import { consoleLogger } from '../logs.js'; + +const getStoragePath = (randomToken: string): string => { + // If exportDirectory is set, use it + if (constants.exportDirectory) { + return constants.exportDirectory; + } + + // Otherwise, use the current working directory + let storagePath = path.join(process.cwd(), 'results', randomToken); + + // Ensure storagePath is writable; if directory doesn't exist, try to create it in Documents or home directory + const isWritable = (() => { + try { + if (!fs.existsSync(storagePath)) { + fs.mkdirSync(storagePath, { recursive: true }); + } + fs.accessSync(storagePath, fs.constants.W_OK); + return true; + } catch { + return false; + } + })(); + + if (!isWritable) { + if (os.platform() === 'win32') { + // Use Documents folder on Windows + const documentsPath = path.join( + process.env.USERPROFILE || process.env.HOMEPATH || '', + 'Documents', + ); + storagePath = path.join(documentsPath, 'Oobee', randomToken); + } else if (os.platform() === 'darwin') { + // Use Documents folder on Mac + const documentsPath = path.join(process.env.HOME || '', 'Documents'); + storagePath = path.join(documentsPath, 'Oobee', randomToken); + } else { + // Use home directory for Linux/other + const homePath = process.env.HOME || ''; + storagePath = path.join(homePath, 'Oobee', randomToken); + } + consoleLogger.warn(`Warning: Cannot write to cwd, writing to ${storagePath}`); + } + + if (!fs.existsSync(storagePath)) { + fs.mkdirSync(storagePath, { recursive: true }); + } + + constants.exportDirectory = storagePath; + return storagePath; +}; + +export default getStoragePath; diff --git a/src/utils/getVersion.ts b/src/utils/getVersion.ts new file mode 100644 index 00000000..39e815b6 --- /dev/null +++ b/src/utils/getVersion.ts @@ -0,0 +1,9 @@ +import fs from 'fs-extra'; + +const getVersion = (): string => { + const loadJSON = (filePath: string): { version: string } => + JSON.parse(fs.readFileSync(new URL(filePath, import.meta.url)).toString()); + return loadJSON('../../package.json').version; +}; + +export default getVersion; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..a27d6a53 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,6 @@ +export { default as getVersion } from './getVersion.js'; +export { default as getHost } from './getHost.js'; +export { default as getCurrentDate } from './getCurrentDate.js'; +export { default as getStoragePath } from './getStoragePath.js'; +export { default as getPdfStoragePath } from './getPdfStoragePath.js'; +export { default as isWhitelistedContentType } from './isWhitelistedContentType.js'; diff --git a/src/utils/isWhitelistedContentType.ts b/src/utils/isWhitelistedContentType.ts new file mode 100644 index 00000000..8b09547b --- /dev/null +++ b/src/utils/isWhitelistedContentType.ts @@ -0,0 +1,6 @@ +const isWhitelistedContentType = (contentType: string): boolean => { + const whitelist = ['text/html']; + return whitelist.filter(type => contentType.trim().startsWith(type)).length === 1; +}; + +export default isWhitelistedContentType;