diff --git a/package-lock.json b/package-lock.json index b75140f2..cd5693c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jrpool/kilotest", - "version": "57.2.0", + "version": "57.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jrpool/kilotest", - "version": "57.2.0", + "version": "57.2.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "*", @@ -6466,9 +6466,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", "dev": true, "license": "MIT", "dependencies": { @@ -6665,9 +6665,9 @@ } }, "node_modules/mdast-util-to-markdown": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.3.tgz", - "integrity": "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", "dev": true, "license": "MIT", "dependencies": { @@ -9260,9 +9260,9 @@ } }, "node_modules/testaro-issues": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/testaro-issues/-/testaro-issues-1.0.1.tgz", - "integrity": "sha512-eaTypxsUuYmYMx3fFFCJ44JUYbqodhNWN+MgPv9Xhf62fnQ/D9t9CIrZazAxRG2wUcCcS1/JiuiRSB1lVwEEiw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/testaro-issues/-/testaro-issues-1.0.2.tgz", + "integrity": "sha512-+0cmWjBd2ZpoyL9FQSOQrMy+uLYvo5fNBJCtisuWMZBXgC6B3a3z5PGJUEuoXXh9kZnn42mHNWQc0CssXjsKbw==", "license": "MIT" }, "node_modules/testaro/node_modules/dotenv": { diff --git a/package.json b/package.json index 9fbc45cd..4207c2f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jrpool/kilotest", - "version": "57.2.2", + "version": "57.2.3", "description": "Ensemble testing for front-end web quality", "type": "module", "main": "index.ts", diff --git a/util.test.ts b/util.test.ts index 343cfe28..1a61a46d 100644 --- a/util.test.ts +++ b/util.test.ts @@ -23,36 +23,26 @@ import { annotateReportObject, awaitJob, checkCommentDuplicate, - checkCommentLength, checkLength, createLock, dbPath, describeMax, getAgoDays, - getAgoString, - getCountString, getDateString, getDateTime, - getEngineNamesString, getEnvMax, getIssue, errorMessage, getJSON, - getMultiReportWhats, getNowStamp, getObject, - getPageData, - getPageDataStrings, getPlainText, getRandomString, getReportExtract, getReportExtracts, getReportPath, getReportStats, - getTextFragmentHref, getTimeStamp, - getWCAGLink, - getWeightName, hiddenReportsPath, htmlSafe, isAllowedRedirectTarget, @@ -64,7 +54,6 @@ import { isURL, isValidAuthCode, jobsPath, - makeBreakable, minifyURL, objectSort, orderJob, @@ -76,32 +65,6 @@ import { // TESTS -test('checkCommentLength accepts a comment of exactly 20 characters', () => { - const result = checkCommentLength('a'.repeat(20)); - assert.deepEqual(result, {status: 'ok'}); -}); - -test('checkCommentLength accepts a comment of exactly 1000 characters', () => { - const result = checkCommentLength('a'.repeat(1000)); - assert.deepEqual(result, {status: 'ok'}); -}); - -test('checkCommentLength rejects a comment shorter than 20 characters', () => { - const result = checkCommentLength('a'.repeat(19)); - assert.deepEqual(result, { - status: 'error', - message: 'Your comment was shorter than 20 characters' - }); -}); - -test('checkCommentLength rejects a comment longer than 1000 characters', () => { - const result = checkCommentLength('a'.repeat(1001)); - assert.deepEqual(result, { - status: 'error', - message: 'Your comment was longer than 1000 characters' - }); -}); - test('checkLength accepts text at the minimum bound', () => { const result = checkLength('a'.repeat(5), 5, 10, 'reason'); assert.deepEqual(result, {status: 'ok'}); @@ -233,28 +196,6 @@ test('getAgoDays returns 0 for the current time', () => { assert.equal(getAgoDays(new Date()), 0); }); -test('getAgoString returns "1 day" for a 1-day-old timestamp', () => { - const date = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); - assert.equal( - getAgoString(date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11)), '1 day' - ); -}); - -test('getAgoString returns "3 days" for a 3-day-old timestamp', () => { - const date = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); - assert.equal( - getAgoString(date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11)), '3 days' - ); -}); - -test('getCountString returns singular for count 1', () => { - assert.equal(getCountString(1, 'report', 'reports'), '1 report'); -}); - -test('getCountString returns plural for count 0', () => { - assert.equal(getCountString(0, 'report', 'reports'), '0 reports'); -}); - test('getDateString returns a valid date string for a valid time stamp', () => { assert.equal(getDateString('260101T0000'), '2026-01-01'); }); @@ -359,24 +300,6 @@ test('getRandomString returns a string of the requested length', () => { assert.equal(s.length, 10); }); -test('getTextFragmentHref returns a text-fragment URL', () => { - const href = getTextFragmentHref('Hello', 'https://example.com/page'); - assert.ok(href.startsWith('https://example.com/page#:~:text=')); -}); - -test('getWCAGLink returns a URL for a numeric WCAG ID', () => { - const link = getWCAGLink('4.1.2'); - assert.ok(link.startsWith('https://www.w3.org/WAI/WCAG22/Understanding/')); -}); - -test('getWeightName returns the correct name for each weight', () => { - assert.equal(getWeightName(1), 'lowest'); - assert.equal(getWeightName(2), 'low'); - assert.equal(getWeightName(3), 'high'); - assert.equal(getWeightName(4), 'highest'); - assert.equal(getWeightName(5), 'unknown'); -}); - test('htmlSafe escapes HTML special characters', () => { assert.equal(htmlSafe(''), '<script>alert("x")</script>'); }); @@ -776,10 +699,6 @@ test('isValidAuthCode returns false for an undefined code', () => { assert.equal(isValidAuthCode(undefined), false); }); -test('makeBreakable inserts wbr before non-initial slashes', () => { - assert.equal(makeBreakable('/api/listReports'), '/api/listReports'); -}); - test('minifyURL removes www. and trailing slash and lowercases', () => { assert.equal(minifyURL('https://www.Example.com/'), 'https://example.com'); }); @@ -821,50 +740,6 @@ after(() => { } }); -test('getPageData returns page data for a valid report', async () => { - const data = await getPageData('260101T0000', 'mix') as any; - assert.equal(data.description, 'Mixed Outcomes Page'); - assert.equal(data.url, 'https://example.com/mixed'); - assert.equal(typeof data.daysAgo, 'number'); -}); - -test('getPageData returns an error for a nonexistent report', async () => { - const data = await getPageData('999999T9999', 'xxx') as any; - assert.ok(data.error); -}); - -test('getPageDataStrings returns HTML strings for a valid report', async () => { - const strings = await getPageDataStrings('260101T0000', 'mix') as any; - assert.equal(strings.description, 'Mixed Outcomes Page'); - assert.equal(strings.url, 'https://example.com/mixed'); - assert.equal(strings.urlLink, 'https://example.com/mixed'); - assert.ok(strings.testInfo.includes('by job mix')); - assert.ok(strings.testInfo.includes('2026-01-01 at 00:00')); -}); - -test('getPageDataStrings returns different testInfo for a different timeStamp', async () => { - const strings = await getPageDataStrings('260101T0001', 'ct') as any; - assert.equal(strings.description, 'All CantTell Page'); - assert.ok(strings.testInfo.includes('by job ct')); - assert.ok(strings.testInfo.includes('2026-01-01 at 00:01')); -}); - -test('getPageDataStrings returns an error for a nonexistent report', async () => { - const strings = await getPageDataStrings('999999T9999', 'xxx'); - assert.ok(strings.error); -}); - -test('getPageDataStrings uses provided pageData instead of reading the report', async () => { - const strings = await getPageDataStrings('260101T0000', 'mix', { - description: 'Custom Page', - url: 'https://custom.com', - daysAgo: 1 - }) as any; - assert.equal(strings.description, 'Custom Page'); - assert.equal(strings.url, 'https://custom.com'); - assert.ok(strings.testInfo.includes('1 day ago')); -}); - test('processTestRequest succeeds for a valid new-test request', {timeout: 500}, async () => { // Reset testRequests.json to empty before the test. await fs.writeFile(testRequestsPath(), '{}\n'); @@ -1358,41 +1233,12 @@ test('getReport returns an error for an invalid report', async () => { } }); -test('getReportData returns an error for a nonexistent report', async () => { - const {getReportData} = await import('./util.ts'); - const result: any = await getReportData('990101T0000', 'xxx'); - assert.ok(result.error); -}); - -test('getReportData falls back to the engine ID for an unknown prevented engine', async () => { - const {getReportData} = await import('./util.ts'); - const prvJSON = await fs.readFile(path.join(reportsPath(), '260101T0006-prv.json'), 'utf8'); - const report = JSON.parse(prvJSON); - report.jobData.preventions.unknownEngine = 'mystery failure'; - const reportPath = path.join(reportsPath(), '260103T0000-unk.json'); - await fs.writeFile(reportPath, JSON.stringify(report)); - try { - const result: any = await getReportData('260103T0000', 'unk'); - assert.ok(result.preventedEngineNames.includes('unknownEngine')); - } - finally { - await fs.unlink(reportPath); - } -}); - // TESTS FOR REMAINING BRANCH COVERAGE IN util.js test('getAgoDays returns null for an invalid Date object', () => { assert.equal(getAgoDays(new Date('invalid')), null); }); -test('getAgoString returns "1 day" for exactly 1 day ago', () => { - // Construct a time stamp 1 day and 1 hour ago, so Math.round gives exactly 1. - const date = new Date(Date.now() - (86400000 + 3600000)); - const stamp = date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11); - assert.equal(getAgoString(stamp), '1 day'); -}); - test('getIssue returns null for a known engine with an unknown variable rule', () => { assert.equal(getIssue('axe', 'nonexistent-rule-id'), null); }); @@ -1403,12 +1249,6 @@ test('getIssue returns an issue ID for a variable rule pattern match', () => { assert.equal(result, 'duplicateAttribute'); }); -test('getDateTimeString describes an invalid time stamp as unknown', async () => { - const {getDateTimeString} = await import('./util.ts'); - const result = getDateTimeString('999999T9999'); - assert.equal(result, 'an unknown date at an unknown time'); -}); - test('getPOSTData resolves with parsed JSON for application/json requests', async () => { const {Readable} = await import('node:stream'); const {getPOSTData} = await import('./util.ts'); @@ -1464,35 +1304,6 @@ test('getPOSTData resolves with null for a malformed JSON body', async () => { assert.equal(result, null); }); -test('getEngineNamesString falls back to the engine ID for an unknown engine', async () => { - const {getEngineNamesString} = await import('./util.ts'); - const result = getEngineNamesString(new Set(['unknownEngine'])); - assert.equal(result, 'unknownEngine'); -}); - -test('getPathID returns the catalog pathID when catalogIndex is truthy', async () => { - const {getPathID} = await import('./util.ts'); - const catalog = {'0': {tagName: 'div', pathID: '/html/body/div'}}; - assert.equal(getPathID(catalog, '0', '/fallback'), '/html/body/div'); -}); - -test('getPathID returns the fallback pathID when catalogIndex is truthy but catalogItem has no pathID', async () => { - const {getPathID} = await import('./util.ts'); - const catalog = {'0': {tagName: 'div', pathID: ''}}; - assert.equal(getPathID(catalog, '0', '/fallback'), '/fallback'); -}); - -test('getPathID returns /html when catalogIndex is truthy but catalogItem and pathID are both missing', async () => { - const {getPathID} = await import('./util.ts'); - const catalog = {}; - assert.equal(getPathID(catalog, '0', null as any), '/html'); -}); - -test('getPathID returns /html when catalogIndex is falsy and pathID is null', async () => { - const {getPathID} = await import('./util.ts'); - assert.equal(getPathID({}, null as any, null as any), '/html'); -}); - test('isUsableReport returns false for a report with a test act using an unknown engine', async () => { const {isUsableReport} = await import('./util.ts'); const report = { @@ -1556,17 +1367,6 @@ test('getTimeStamp returns an 11-character stamp from a Date', () => { assert.equal(stamp.slice(7), '1430'); }); -test('getEngineNamesString returns a sorted +-delimited list of engine names', () => { - const result = getEngineNamesString(new Set(['axe', 'wave', 'nuVal'])); - const names = result.split(' + '); - assert.ok(names.length === 3); - assert.ok(names.includes('WAVE')); -}); - -test('getEngineNamesString falls back to the ID for an unknown engine', () => { - assert.equal(getEngineNamesString(new Set(['unknownEngine'])), 'unknownEngine'); -}); - test('testRequestsLock is a function (the lock returned by createLock)', () => { assert.equal(typeof testRequestsLock, 'function'); }); @@ -1619,11 +1419,6 @@ test('getReportExtracts with onlyLatest returns only the latest report for each assert.equal(mixReports[0]!.timeStamp, '260202T0000'); }); -test('getMultiReportWhats returns descriptions that have multiple reports', async () => { - const whats = await getMultiReportWhats(); - assert.ok(whats.includes('Mixed Outcomes Page')); -}); - test('getExclusionCookieValue is deterministic for a given AUTH_CODE', async () => { const savedAuthCode = process.env.AUTH_CODE; process.env.AUTH_CODE = 'test-auth-code'; diff --git a/util.ts b/util.ts index e9e62f73..246f3056 100644 --- a/util.ts +++ b/util.ts @@ -9,7 +9,7 @@ // c8 intermittently reports import lines as uncovered due to a range-merge // artifact in its remapping of Node's type-stripped source. import {sendAlert} from './alerts.ts'; -import {issues as issueSpecs, rules as ruleSpecs} from 'testaro-issues'; +import {rules as ruleSpecs} from 'testaro-issues'; import type {Act, Catalog, Report, StandardInstance} from 'testaro'; import crypto from 'node:crypto'; import dns from 'node:dns/promises'; @@ -19,7 +19,6 @@ import net from 'node:net'; import path from 'node:path'; import querystring from 'node:querystring'; /* c8 ignore stop */ -import wcagMap from './wcagMap.json' with {type: 'json'}; // CONSTANTS @@ -139,47 +138,10 @@ export type ReportExtract = { superseded?: boolean; }; -// Page data from an available report. -export type PageData = { - description: string; - url: string; - daysAgo: number | null; - error?: never; -}; - -// HTML strings describing the page data of an available report. -export type PageDataStrings = { - description: string; - url: string; - urlLink: string; - testInfo: string; - error?: never; -}; - -// Basics about an available report. -export type ReportData = { - description: string; - url: string; - jobName: unknown; - creationDate: Date | null; - daysAgo: number | null; - issueCount: number; - engineNames: string[]; - engineCount: number; - reporterNames: string[]; - reporterCount: number; - violatorCount: number; - preventedEngineNames: string[]; - preventedEngineCount: number; - error?: never; -}; - // MISCELLANEOUS FUNCTIONS // Compares strings alphabetically and case-insensitively. -const alphaCompare = (a: string, b: string) => a.localeCompare(b, 'en', {sensitivity: 'base'}); -// Sorts strings alphabetically and case-insensitively. -const alphaSort = (strings: string[]) => strings.sort((a, b) => alphaCompare(a, b)); +export const alphaCompare = (a: string, b: string) => a.localeCompare(b, 'en', {sensitivity: 'base'}); // Returns whether a string's length is within the given bounds. Generic length check // shared by any field with a min/max character-count rule (e.g. a request description // or reason), so each caller states its own bounds and label instead of hardcoding an @@ -198,22 +160,6 @@ export const checkLength = ( } return {status: 'ok'}; }; -// Returns whether a comment's raw length is within the 20-to-1000-character bounds. -export const checkCommentLength = ( - content: string -): {status: 'ok'} | {status: 'error'; message: string} => { - const contentLength = content.length; - // If the length is invalid: - if (contentLength < 20 || contentLength > 1000) { - // Return the applicable error message. - const messageSpec = contentLength < 20 ? 'shorter than 20' : 'longer than 1000'; - return { - status: 'error', - message: `Your comment was ${messageSpec} characters` - }; - } - return {status: 'ok'}; -}; // Returns whether a comment repeats, verbatim, one of the given comments, regardless // of when the earlier one was submitted: a legitimate need to resubmit exactly the // same content never arises (the near-term case is a browser "Back" resubmission), @@ -244,10 +190,6 @@ export const createLock = (): ((fn: () => T | Promise) => Promise) => { return result; }; }; -// Returns a string encoded for use as a URL fragment. -const fragmentEncode = (string: string) => { - return encodeURIComponent(string).replace(/-/g, '%2D'); -}; // Returns the time in days since a Date or time stamp, or null if the argument is invalid. export const getAgoDays = (timeArg: string | Date): number | null => { let dateTime; @@ -274,16 +216,6 @@ export const getAgoDays = (timeArg: string | Date): number | null => { // Otherwise, i.e. if it is valid, return the elapsed days since then. return Math.round((Date.now() - dateTime.getTime()) / (1000 * 60 * 60 * 24)); }; -// Returns a string describing the time in days since a time stamp. -export const getAgoString = (timeStamp: string): string => { - const agoDays = getAgoDays(timeStamp); - if (agoDays === null) { - return 'an unknown number of days'; - } - return agoDays === 1 ? '1 day' : `${agoDays} days`; -}; -// Returns a string describing a count. -export const getCountString = (count: number, singular: string, plural: string): string => count === 1 ? `1 ${singular}` : `${count} ${plural}`; // Returns a date string from a time stamp. export const getDateString = (timeStamp: string): string => { const dateString = `20${timeStamp.slice(0, 2)}-${timeStamp.slice(2, 4)}-${timeStamp.slice(4,6)}`; @@ -435,19 +367,6 @@ export const getTimeStamp = (date: Date): string => { export const getNowStamp = (): string => { return getTimeStamp(new Date()); }; -// Returns a time string from a time stamp. -const getTimeString = (timeStamp: string) => { - const timeString = `${timeStamp.slice(7, 9)}:${timeStamp.slice(9, 11)}`; - // Return the time string if valid, or null if not. - return (!isNaN(Date.parse(`2000-01-01T${timeString}Z`))) ? timeString : null; -}; -// Returns a date-and-time string. -export const getDateTimeString = (timeStamp: string): string => { - const dateString = getDateString(timeStamp) || 'an unknown date'; - const timeString = getTimeString(timeStamp) || 'an unknown time'; - const dateTimeString = `${dateString} at ${timeString}`; - return dateTimeString; -} // Converts a string to a plain-text 1-line ASCII string. export const getPlainText = (string: string): string => string .replace(/&/g, '+') @@ -512,26 +431,6 @@ export const getTestRequests = async (): Promise => { throw new Error('Test-requests file not JSON', {cause: error}); } }; -// Converts a catalog item text to a text-fragment link destination. -export const getTextFragmentHref = (text: string, url: string): string => { - const fragmentList = text - .split('\n') - .map(fragment => fragmentEncode(fragment)) - .join(','); - // Return a text-fragment link. - return `${url}#:~:text=${fragmentList}`; -}; -// Returns a +-delimited list of sorted names of rule engines. -export const getEngineNamesString = (engineIDSet: Iterable): string => alphaSort( - Array.from(engineIDSet).map(engineID => ruleEngines[engineID]?.[0] || engineID) -).join(' + '); -// Gets the WCAG Understanding link for a numeric WCAG standard identifier. -export const getWCAGLink = (numericID: string): string => { - // Return the link. - return `https://www.w3.org/WAI/WCAG22/Understanding/${(wcagMap as Record)[numericID]}`; -}; -// Gets the name of an issue weight. -export const getWeightName = (weight: number): string => ['lowest', 'low', 'high', 'highest'][weight - 1] ?? 'unknown'; // Makes a string HTML-safe. export const htmlSafe = (string: string): string => string ? string .replace(/&/g, '&') @@ -674,8 +573,6 @@ export const isAllowedRedirectTarget = async ( export const isValidAuthCode = (authCode: string | undefined | null): boolean => { return !!authCode && authCode === process.env.AUTH_CODE; }; -// Makes a string breakable before non-initial slashes. -export const makeBreakable = (string: string): string => string.replace(/\//g, '/').replace(/^/, ''); // Minifies a URL for duplicate detection. export const minifyURL = (url: string): string => url.replace(/www\.|\/$/g, '').toLowerCase(); // Sorts objects by a property value and returns the sorted array. @@ -770,16 +667,6 @@ export const getTestActInstances = ( } return pairs; }; -// Returns the path ID of the element of a standard instance. -export const getPathID = (catalog: Catalog, catalogIndex: string, pathID?: string) => { - if (catalogIndex) { - const pathIDFromCatalog = (catalog[catalogIndex] || {}).pathID; - if (pathIDFromCatalog) { - return pathIDFromCatalog; - } - } - return pathID ?? '/html'; -}; // Returns the path of an available report file. export const getReportPath = (timeStamp: string, jobID: string): string => path .join(reportsPath(), `${timeStamp}-${jobID}.json`); @@ -866,122 +753,6 @@ export const annotateReportObject = async (report: UsableReport): Promise ); } }; -// Returns basics about an available report. -export const getReportData = async (timeStamp: string, jobID: string): Promise => { - // Get the report. - const report = await getReport(timeStamp, jobID); - // If this failed: - if (isReportError(report)) { - // Return why. - return {error: report.error}; - } - // Otherwise, i.e. if it succeeded, initialize the data. - const data = { - description: report.target.what, - url: report.target.url, - jobName: report.id, - creationDate: getDateTime(timeStamp), - daysAgo: getAgoDays(timeStamp), - issueCount: 0, - engineNames: [] as string[], - engineCount: 0, - reporterNames: [] as string[], - reporterCount: 0, - violatorCount: 0, - preventedEngineNames: [] as string[], - preventedEngineCount: 0 - }; - const issueIDSet = new Set(); - const engineNameSet = new Set(); - const reporterIDSet = new Set(); - const violatorIndexSet = new Set(); - // For each test act of the report: - getTestActs(report).forEach(act => { - // Ensure that the rule engine is in the temporary data. - engineNameSet.add(ruleEngines[act.which!]![0]); - }); - // For each violating standard instance of each test act: - getTestActInstances(report, {violationsOnly: true}).forEach(({act, instance}) => { - const {catalogIndex, issueID} = instance; - // If it has a non-ignorable classified issue ID: - if (issueID && issueSpecs[issueID] && issueID !== 'ignorable') { - // Ensure that the rule engine is in the temporary data. - reporterIDSet.add(act.which!); - // Ensure that the issue is in the temporary data. - issueIDSet.add(issueID); - // If the violator has a catalog index: - if (catalogIndex) { - // Ensure that the violator is in the temporary data. - violatorIndexSet.add(String(catalogIndex)); - } - } - }); - // Populate the data with the act data. - data.issueCount = issueIDSet.size; - data.engineNames = Array - .from(engineNameSet) - .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'})); - data.engineCount = engineNameSet.size; - data.reporterNames = Array - .from(reporterIDSet) - .map(id => ruleEngines[id]![0]) - .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'})); - data.reporterCount = data.reporterNames.length; - data.violatorCount = violatorIndexSet.size; - // Add the names of any prevented rule engines to the data. - data.preventedEngineNames = Object.keys(report.jobData?.preventions || {}) - .map(engineID => ruleEngines[engineID]?.[0] || engineID) - .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'})); - data.preventedEngineCount = data.preventedEngineNames.length; - // Return the data. - return data; -} -// Returns page data from an available report. -export const getPageData = async (timeStamp: string, jobID: string): Promise => { - // Get the report. - const report = await getReport(timeStamp, jobID); - // If this failed: - if (isReportError(report)) { - // Return why. - return report; - } - const {what: description, url} = report.target; - // Get the elapsed time in days since the report was completed, using the - // report content rather than the file system birth time. - const daysAgo = getAgoDays(new Date(`20${report.jobData.endTime}Z`)); - // Return the data. - return { - description, - url, - daysAgo - }; -}; -// Gets HTML strings for page data from a report. -export const getPageDataStrings = async ( - timeStamp: string, - jobID: string, - pageData?: PageData | {error: string} -): Promise => { - // Get the page data if they were not specified. - const data = pageData ?? await getPageData(timeStamp, jobID); - // If the page data are invalid: - if (data.error !== undefined) { - // Return why. - return { - error: data.error - }; - } - const {daysAgo, url, description} = data; - // Otherwise, i.e. if they are valid, get a description of the timestamp. - const when = getDateTimeString(timeStamp); - // Return the HTML strings. - return { - description, - url, - urlLink: `${url}`, - testInfo: `Tested ${daysAgo === 1 ? '1 day' : `${daysAgo} days`} ago by job ${jobID} on ${when}` - }; -}; // Returns the creation time and size of a report. export const getReportStats = async (timeStamp: string, jobID: string) => { let reportStat; @@ -1058,15 +829,6 @@ export const isReportAvailable = async (description: string, url: string): Promi const miniURLs = reportExtracts.map(reportExtract => minifyURL(reportExtract.url)); return descriptions.includes(description) || miniURLs.includes(minifyURL(url)); }; -// Gets the descriptions of multi-report pages. -export const getMultiReportWhats = async (): Promise => { - const reportExtracts = await getReportExtracts(); - const sortedDescriptions = reportExtracts.map(extract => extract.description).sort(); - const multiReportDescriptions = sortedDescriptions.filter( - (description, index) => description !== sortedDescriptions[index - 1] && description === sortedDescriptions[index + 1] - ); - return multiReportDescriptions; -}; // Returns the property that an approved job in a category has. const getApprovedJobProperty = async ( description: string, url: string, category: 'claimed' | 'queue' diff --git a/web/deleteNotesForm/index.ts b/web/deleteNotesForm/index.ts index b81e5153..73f8ea7c 100644 --- a/web/deleteNotesForm/index.ts +++ b/web/deleteNotesForm/index.ts @@ -7,7 +7,14 @@ // IMPORTS -import {errorMessage, getDateTimeString, getJSON, getPlainText, isValidAuthCode, populateTemplate} from '../../util.ts'; +import { + errorMessage, + getJSON, + getPlainText, + isValidAuthCode, + populateTemplate +} from '../../util.ts'; +import {getDateTimeString} from '../util.ts'; import fs from 'node:fs/promises'; import path from 'node:path'; diff --git a/web/listDiagnoses/index.ts b/web/listDiagnoses/index.ts index 57000dbb..25cf7c75 100644 --- a/web/listDiagnoses/index.ts +++ b/web/listDiagnoses/index.ts @@ -6,17 +6,19 @@ // IMPORTS import { - getPageDataStrings, getReport, getTestActInstances, - getTextFragmentHref, - getWCAGLink, - getWeightName, htmlSafe, isReportError, populateTemplate, ruleEngines } from '../../util.ts'; +import { + getPageDataStrings, + getTextFragmentHref, + getWCAGLink, + getWeightName +} from '../util.ts'; import {issues as issueSpecs} from 'testaro-issues'; // FUNCTIONS diff --git a/web/listDiagnoses/listDiagnoses.test.ts b/web/listDiagnoses/listDiagnoses.test.ts index d15fa7c1..f68409a1 100644 --- a/web/listDiagnoses/listDiagnoses.test.ts +++ b/web/listDiagnoses/listDiagnoses.test.ts @@ -14,6 +14,7 @@ import {parse} from 'node-html-parser'; // Mock the util functions called by index.ts before requiring it, so that // it imports the mocked versions. Other exports delegate to the real module. import * as realUtil from '../../util.ts'; +import * as realWebUtil from '../util.ts'; let getReportCallCount = 0; let failGetReportOnCall = -1; let pageDataStringsOverride: any = null; @@ -26,12 +27,17 @@ mock.module('../../util.ts', { return {error: 'Report is invalid (test override)'}; } return (realUtil.getReport as any)(...args); - }, + } + } +} as any); +mock.module('../util.ts', { + exports: { + ...realWebUtil, getPageDataStrings: async (...args: any[]) => { if (pageDataStringsOverride !== null) { return pageDataStringsOverride; } - return (realUtil.getPageDataStrings as any)(...args); + return (realWebUtil.getPageDataStrings as any)(...args); } } } as any); diff --git a/web/listIssues/index.ts b/web/listIssues/index.ts index 742e570b..ccf47a9d 100644 --- a/web/listIssues/index.ts +++ b/web/listIssues/index.ts @@ -6,19 +6,21 @@ // IMPORTS import { - getPageData, - getPageDataStrings, getReport, - getEngineNamesString, getTestActInstances, - getWCAGLink, - getWeightName, htmlSafe, isUsableReport, objectSort, populateTemplate, ruleEngines } from '../../util.ts'; +import { + getPageData, + getPageDataStrings, + getEngineNamesString, + getWCAGLink, + getWeightName +} from '../util.ts'; import {issues as issueSpecs} from 'testaro-issues'; // FUNCTIONS diff --git a/web/listIssues/listIssues.test.ts b/web/listIssues/listIssues.test.ts index e8242bfc..ed6d9b7d 100644 --- a/web/listIssues/listIssues.test.ts +++ b/web/listIssues/listIssues.test.ts @@ -13,15 +13,16 @@ import {parse} from 'node-html-parser'; // Mock the util functions called by index.ts before requiring it, so that // it imports the mocked versions. Other exports delegate to the real module. import * as realUtil from '../../util.ts'; +import * as realWebUtil from '../util.ts'; let pageDataStringsOverride: any = null; -mock.module('../../util.ts', { +mock.module('../util.ts', { exports: { - ...realUtil, + ...realWebUtil, getPageDataStrings: async (...args: any[]) => { if (pageDataStringsOverride !== null) { return pageDataStringsOverride; } - return (realUtil.getPageDataStrings as any)(...args); + return (realWebUtil.getPageDataStrings as any)(...args); } } } as any); diff --git a/web/listReports/index.ts b/web/listReports/index.ts index 6932dd32..6cf9a604 100644 --- a/web/listReports/index.ts +++ b/web/listReports/index.ts @@ -7,18 +7,20 @@ import { getAgoDays, - getCountString, getJobNames, - getMultiReportWhats, getObject, - getPageDataStrings, getTestRequests, - getReportData, getReportExtracts, jobsPath, objectSort, populateTemplate } from '../../util.ts'; +import { + getCountString, + getMultiReportWhats, + getPageDataStrings, + getReportData +} from '../util.ts'; import path from 'node:path'; // FUNCTIONS diff --git a/web/listRules/index.ts b/web/listRules/index.ts index 3116c6f5..6de6e3c7 100644 --- a/web/listRules/index.ts +++ b/web/listRules/index.ts @@ -6,11 +6,13 @@ // IMPORTS import { - getWeightName, htmlSafe, populateTemplate, ruleEngines } from '../../util.ts'; +import { + getWeightName +} from '../util.ts'; import {issues as issueSpecs, issueRules, rules as ruleSpecs} from 'testaro-issues'; // FUNCTIONS diff --git a/web/listTopIssues/index.ts b/web/listTopIssues/index.ts index 495ad606..4510ba91 100644 --- a/web/listTopIssues/index.ts +++ b/web/listTopIssues/index.ts @@ -7,16 +7,18 @@ import {sendAlert} from '../../alerts.ts'; import { - getEngineNamesString, getReportExtracts, getReport, getTestActInstances, - getWCAGLink, - getWeightName, isReportError, objectSort, - populateTemplate, + populateTemplate } from '../../util.ts'; +import { + getEngineNamesString, + getWCAGLink, + getWeightName +} from '../util.ts'; import {issues as issueSpecs} from 'testaro-issues'; // FUNCTIONS diff --git a/web/listViolators/index.ts b/web/listViolators/index.ts index 7c645bb0..75b4a5e8 100644 --- a/web/listViolators/index.ts +++ b/web/listViolators/index.ts @@ -5,20 +5,22 @@ // IMPORTS +import { + getReport, + getTestActInstances, + htmlSafe, + isReportError, + populateTemplate +} from '../../util.ts'; import { getPageDataStrings, getPathID, - getReport, getEngineNamesString, - getTestActInstances, getTextFragmentHref, getWCAGLink, getWeightName, - htmlSafe, - isReportError, - makeBreakable, - populateTemplate, -} from '../../util.ts'; + makeBreakable +} from '../util.ts'; import {issues as issueSpecs} from 'testaro-issues'; // FUNCTIONS diff --git a/web/listViolators/listViolators.test.ts b/web/listViolators/listViolators.test.ts index 57ce9050..ed830541 100644 --- a/web/listViolators/listViolators.test.ts +++ b/web/listViolators/listViolators.test.ts @@ -14,6 +14,7 @@ import {parse} from 'node-html-parser'; // Mock the util functions called by index.ts before requiring it, so that // it imports the mocked versions. Other exports delegate to the real module. import * as realUtil from '../../util.ts'; +import * as realWebUtil from '../util.ts'; let getReportCallCount = 0; let failGetReportOnCall = -1; let pageDataStringsOverride: any = null; @@ -26,12 +27,17 @@ mock.module('../../util.ts', { return {error: 'Report is invalid (test override)'}; } return (realUtil.getReport as any)(...args); - }, + } + } +} as any); +mock.module('../util.ts', { + exports: { + ...realWebUtil, getPageDataStrings: async (...args: any[]) => { if (pageDataStringsOverride !== null) { return pageDataStringsOverride; } - return (realUtil.getPageDataStrings as any)(...args); + return (realWebUtil.getPageDataStrings as any)(...args); } } } as any); diff --git a/web/metrics/index.ts b/web/metrics/index.ts index 41a18f2c..0c27a494 100644 --- a/web/metrics/index.ts +++ b/web/metrics/index.ts @@ -7,9 +7,16 @@ // IMPORTS import { - clearMetrics, getDateTimeString, getExclusionCookieValue, getMetrics, isValidAuthCode, - metricsExclusionCookieName, populateTemplate + clearMetrics, + getExclusionCookieValue, + getMetrics, + isValidAuthCode, + metricsExclusionCookieName, + populateTemplate } from '../../util.ts'; +import { + getDateTimeString +} from '../util.ts'; // FUNCTIONS diff --git a/web/reportDeletion.ts b/web/reportDeletion.ts index c0e51fbb..f2217bd3 100644 --- a/web/reportDeletion.ts +++ b/web/reportDeletion.ts @@ -6,9 +6,16 @@ // IMPORTS import { - errorMessage, getReportData, isValidAuthCode, objectSort, populateTemplate, readdirOrCreate, + errorMessage, + isValidAuthCode, + objectSort, + populateTemplate, + readdirOrCreate, reportsPath } from '../util.ts'; +import { + getReportData +} from './util.ts'; import fs from 'node:fs/promises'; import path from 'node:path'; diff --git a/web/requestRetestForm/index.ts b/web/requestRetestForm/index.ts index c00965ca..e556b79f 100644 --- a/web/requestRetestForm/index.ts +++ b/web/requestRetestForm/index.ts @@ -5,7 +5,8 @@ // IMPORTS -import {getAgoString, getDateTimeString, getReportExtracts, populateTemplate} from '../../util.ts'; +import {getReportExtracts, populateTemplate} from '../../util.ts'; +import {getAgoString, getDateTimeString} from '../util.ts'; // FUNCTIONS diff --git a/web/tutorialAI/index.ts b/web/tutorialAI/index.ts index 913eb608..e2fe7d53 100644 --- a/web/tutorialAI/index.ts +++ b/web/tutorialAI/index.ts @@ -6,7 +6,8 @@ // IMPORTS import {sendAlert} from '../../alerts.ts'; -import {checkCommentDuplicate, checkCommentLength, describeMax, getEnvMax, getJSON, getNowStamp} from '../../util.ts'; +import {checkCommentDuplicate, describeMax, getEnvMax, getJSON, getNowStamp} from '../../util.ts'; +import {checkCommentLength} from '../util.ts'; import fs from 'node:fs/promises'; import path from 'node:path'; diff --git a/web/tutorialWeb/index.ts b/web/tutorialWeb/index.ts index 8658e089..41972c86 100644 --- a/web/tutorialWeb/index.ts +++ b/web/tutorialWeb/index.ts @@ -6,7 +6,8 @@ // IMPORTS import {sendAlert} from '../../alerts.ts'; -import {checkCommentDuplicate, checkCommentLength, describeMax, getEnvMax, getJSON, getNowStamp} from '../../util.ts'; +import {checkCommentDuplicate, describeMax, getEnvMax, getJSON, getNowStamp} from '../../util.ts'; +import {checkCommentLength} from '../util.ts'; import fs from 'node:fs/promises'; import path from 'node:path'; diff --git a/web/util.test.ts b/web/util.test.ts new file mode 100644 index 00000000..c4c1b5ad --- /dev/null +++ b/web/util.test.ts @@ -0,0 +1,240 @@ +/* + util.test.ts + Tests of the utility functions used only by the web pages. +*/ + +// IMPORTS + +import {test, before, after} from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import {reportsPath} from '../util.ts'; +import { + checkCommentLength, + getAgoString, + getCountString, + getEngineNamesString, + getMultiReportWhats, + getPageData, + getPageDataStrings, + getTextFragmentHref, + getWCAGLink, + getWeightName, + makeBreakable +} from './util.ts'; + +// SETUP AND TEARDOWN + +// These tests use the fixture database directory. +import {fixtureDBDir as fixtureDbDir} from '../test/dbFixture.ts'; +const savedDbDir = process.env.DB_DIR; + +before(() => { + process.env.DB_DIR = fixtureDbDir; +}); + +after(() => { + if (savedDbDir !== undefined) { + process.env.DB_DIR = savedDbDir; + } + else { + delete process.env.DB_DIR; + } +}); + +// TESTS + +test('checkCommentLength accepts a comment of exactly 20 characters', () => { + const result = checkCommentLength('a'.repeat(20)); + assert.deepEqual(result, {status: 'ok'}); +}); + +test('checkCommentLength accepts a comment of exactly 1000 characters', () => { + const result = checkCommentLength('a'.repeat(1000)); + assert.deepEqual(result, {status: 'ok'}); +}); + +test('checkCommentLength rejects a comment shorter than 20 characters', () => { + const result = checkCommentLength('a'.repeat(19)); + assert.deepEqual(result, { + status: 'error', + message: 'Your comment was shorter than 20 characters' + }); +}); + +test('checkCommentLength rejects a comment longer than 1000 characters', () => { + const result = checkCommentLength('a'.repeat(1001)); + assert.deepEqual(result, { + status: 'error', + message: 'Your comment was longer than 1000 characters' + }); +}); + +test('getAgoString returns "1 day" for a 1-day-old timestamp', () => { + const date = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); + assert.equal( + getAgoString(date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11)), '1 day' + ); +}); + +test('getAgoString returns "3 days" for a 3-day-old timestamp', () => { + const date = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); + assert.equal( + getAgoString(date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11)), '3 days' + ); +}); + +test('getCountString returns singular for count 1', () => { + assert.equal(getCountString(1, 'report', 'reports'), '1 report'); +}); + +test('getCountString returns plural for count 0', () => { + assert.equal(getCountString(0, 'report', 'reports'), '0 reports'); +}); + +test('getTextFragmentHref returns a text-fragment URL', () => { + const href = getTextFragmentHref('Hello', 'https://example.com/page'); + assert.ok(href.startsWith('https://example.com/page#:~:text=')); +}); + +test('getWCAGLink returns a URL for a numeric WCAG ID', () => { + const link = getWCAGLink('4.1.2'); + assert.ok(link.startsWith('https://www.w3.org/WAI/WCAG22/Understanding/')); +}); + +test('getWeightName returns the correct name for each weight', () => { + assert.equal(getWeightName(1), 'lowest'); + assert.equal(getWeightName(2), 'low'); + assert.equal(getWeightName(3), 'high'); + assert.equal(getWeightName(4), 'highest'); + assert.equal(getWeightName(5), 'unknown'); +}); + +test('makeBreakable inserts wbr before non-initial slashes', () => { + assert.equal(makeBreakable('/api/listReports'), '/api/listReports'); +}); + +test('getPageData returns page data for a valid report', async () => { + const data = await getPageData('260101T0000', 'mix') as any; + assert.equal(data.description, 'Mixed Outcomes Page'); + assert.equal(data.url, 'https://example.com/mixed'); + assert.equal(typeof data.daysAgo, 'number'); +}); + +test('getPageData returns an error for a nonexistent report', async () => { + const data = await getPageData('999999T9999', 'xxx') as any; + assert.ok(data.error); +}); + +test('getPageDataStrings returns HTML strings for a valid report', async () => { + const strings = await getPageDataStrings('260101T0000', 'mix') as any; + assert.equal(strings.description, 'Mixed Outcomes Page'); + assert.equal(strings.url, 'https://example.com/mixed'); + assert.equal(strings.urlLink, 'https://example.com/mixed'); + assert.ok(strings.testInfo.includes('by job mix')); + assert.ok(strings.testInfo.includes('2026-01-01 at 00:00')); +}); + +test('getPageDataStrings returns different testInfo for a different timeStamp', async () => { + const strings = await getPageDataStrings('260101T0001', 'ct') as any; + assert.equal(strings.description, 'All CantTell Page'); + assert.ok(strings.testInfo.includes('by job ct')); + assert.ok(strings.testInfo.includes('2026-01-01 at 00:01')); +}); + +test('getPageDataStrings returns an error for a nonexistent report', async () => { + const strings = await getPageDataStrings('999999T9999', 'xxx'); + assert.ok(strings.error); +}); + +test('getPageDataStrings uses provided pageData instead of reading the report', async () => { + const strings = await getPageDataStrings('260101T0000', 'mix', { + description: 'Custom Page', + url: 'https://custom.com', + daysAgo: 1 + }) as any; + assert.equal(strings.description, 'Custom Page'); + assert.equal(strings.url, 'https://custom.com'); + assert.ok(strings.testInfo.includes('1 day ago')); +}); + +test('getReportData returns an error for a nonexistent report', async () => { + const {getReportData} = await import('./util.ts'); + const result: any = await getReportData('990101T0000', 'xxx'); + assert.ok(result.error); +}); + +test('getReportData falls back to the engine ID for an unknown prevented engine', async () => { + const {getReportData} = await import('./util.ts'); + const prvJSON = await fs.readFile(path.join(reportsPath(), '260101T0006-prv.json'), 'utf8'); + const report = JSON.parse(prvJSON); + report.jobData.preventions.unknownEngine = 'mystery failure'; + const reportPath = path.join(reportsPath(), '260103T0000-unk.json'); + await fs.writeFile(reportPath, JSON.stringify(report)); + try { + const result: any = await getReportData('260103T0000', 'unk'); + assert.ok(result.preventedEngineNames.includes('unknownEngine')); + } + finally { + await fs.unlink(reportPath); + } +}); + +test('getAgoString returns "1 day" for exactly 1 day ago', () => { + // Construct a time stamp 1 day and 1 hour ago, so Math.round gives exactly 1. + const date = new Date(Date.now() - (86400000 + 3600000)); + const stamp = date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11); + assert.equal(getAgoString(stamp), '1 day'); +}); + +test('getDateTimeString describes an invalid time stamp as unknown', async () => { + const {getDateTimeString} = await import('./util.ts'); + const result = getDateTimeString('999999T9999'); + assert.equal(result, 'an unknown date at an unknown time'); +}); + +test('getEngineNamesString falls back to the engine ID for an unknown engine', async () => { + const {getEngineNamesString} = await import('./util.ts'); + const result = getEngineNamesString(new Set(['unknownEngine'])); + assert.equal(result, 'unknownEngine'); +}); + +test('getPathID returns the catalog pathID when catalogIndex is truthy', async () => { + const {getPathID} = await import('./util.ts'); + const catalog = {'0': {tagName: 'div', pathID: '/html/body/div'}}; + assert.equal(getPathID(catalog, '0', '/fallback'), '/html/body/div'); +}); + +test('getPathID returns the fallback pathID when catalogIndex is truthy but catalogItem has no pathID', async () => { + const {getPathID} = await import('./util.ts'); + const catalog = {'0': {tagName: 'div', pathID: ''}}; + assert.equal(getPathID(catalog, '0', '/fallback'), '/fallback'); +}); + +test('getPathID returns /html when catalogIndex is truthy but catalogItem and pathID are both missing', async () => { + const {getPathID} = await import('./util.ts'); + const catalog = {}; + assert.equal(getPathID(catalog, '0', null as any), '/html'); +}); + +test('getPathID returns /html when catalogIndex is falsy and pathID is null', async () => { + const {getPathID} = await import('./util.ts'); + assert.equal(getPathID({}, null as any, null as any), '/html'); +}); + +test('getEngineNamesString returns a sorted +-delimited list of engine names', () => { + const result = getEngineNamesString(new Set(['axe', 'wave', 'nuVal'])); + const names = result.split(' + '); + assert.ok(names.length === 3); + assert.ok(names.includes('WAVE')); +}); + +test('getEngineNamesString falls back to the ID for an unknown engine', () => { + assert.equal(getEngineNamesString(new Set(['unknownEngine'])), 'unknownEngine'); +}); + +test('getMultiReportWhats returns descriptions that have multiple reports', async () => { + const whats = await getMultiReportWhats(); + assert.ok(whats.includes('Mixed Outcomes Page')); +}); diff --git a/web/util.ts b/web/util.ts new file mode 100644 index 00000000..7529a1b4 --- /dev/null +++ b/web/util.ts @@ -0,0 +1,266 @@ +/* + util.ts + Utility functions. +*/ + +// IMPORTS + +/* c8 ignore start */ +// c8 intermittently reports import lines as uncovered due to a range-merge +// artifact in its remapping of Node's type-stripped source. +import { + alphaCompare, + getAgoDays, + getDateString, + getDateTime, + getReport, + getReportExtracts, + getTestActInstances, + getTestActs, + isReportError, + ruleEngines +} from '../util.ts'; +import {issues as issueSpecs} from 'testaro-issues'; +import type {Catalog} from 'testaro'; +/* c8 ignore stop */ +import wcagMap from '../wcagMap.json' with {type: 'json'}; + +// TYPES + +// Page data from an available report. +export type PageData = { + description: string; + url: string; + daysAgo: number | null; + error?: never; +}; +// HTML strings describing the page data of an available report. +export type PageDataStrings = { + description: string; + url: string; + urlLink: string; + testInfo: string; + error?: never; +}; +// Basics about an available report. +export type ReportData = { + description: string; + url: string; + jobName: unknown; + creationDate: Date | null; + daysAgo: number | null; + issueCount: number; + engineNames: string[]; + engineCount: number; + reporterNames: string[]; + reporterCount: number; + violatorCount: number; + preventedEngineNames: string[]; + preventedEngineCount: number; + error?: never; +}; + +// FUNCTIONS + +// Sorts strings alphabetically and case-insensitively. +const alphaSort = (strings: string[]) => strings.sort((a, b) => alphaCompare(a, b)); +// Returns whether a comment's raw length is within the 20-to-1000-character bounds. +export const checkCommentLength = ( + content: string +): {status: 'ok'} | {status: 'error'; message: string} => { + const contentLength = content.length; + // If the length is invalid: + if (contentLength < 20 || contentLength > 1000) { + // Return the applicable error message. + const messageSpec = contentLength < 20 ? 'shorter than 20' : 'longer than 1000'; + return { + status: 'error', + message: `Your comment was ${messageSpec} characters` + }; + } + return {status: 'ok'}; +}; +// Returns a string encoded for use as a URL fragment. +const fragmentEncode = (string: string) => { + return encodeURIComponent(string).replace(/-/g, '%2D'); +}; +// Returns a string describing the time in days since a time stamp. +export const getAgoString = (timeStamp: string): string => { + const agoDays = getAgoDays(timeStamp); + if (agoDays === null) { + return 'an unknown number of days'; + } + return agoDays === 1 ? '1 day' : `${agoDays} days`; +}; +// Returns a string describing a count. +export const getCountString = (count: number, singular: string, plural: string): string => count === 1 ? `1 ${singular}` : `${count} ${plural}`; +// Returns a time string from a time stamp. +const getTimeString = (timeStamp: string) => { + const timeString = `${timeStamp.slice(7, 9)}:${timeStamp.slice(9, 11)}`; + // Return the time string if valid, or null if not. + return (!isNaN(Date.parse(`2000-01-01T${timeString}Z`))) ? timeString : null; +}; +// Returns a date-and-time string. +export const getDateTimeString = (timeStamp: string): string => { + const dateString = getDateString(timeStamp) || 'an unknown date'; + const timeString = getTimeString(timeStamp) || 'an unknown time'; + const dateTimeString = `${dateString} at ${timeString}`; + return dateTimeString; +} +// Converts a catalog item text to a text-fragment link destination. +export const getTextFragmentHref = (text: string, url: string): string => { + const fragmentList = text + .split('\n') + .map(fragment => fragmentEncode(fragment)) + .join(','); + // Return a text-fragment link. + return `${url}#:~:text=${fragmentList}`; +}; +// Returns a +-delimited list of sorted names of rule engines. +export const getEngineNamesString = (engineIDSet: Iterable): string => alphaSort( + Array.from(engineIDSet).map(engineID => ruleEngines[engineID]?.[0] || engineID) +).join(' + '); +// Gets the WCAG Understanding link for a numeric WCAG standard identifier. +export const getWCAGLink = (numericID: string): string => { + // Return the link. + return `https://www.w3.org/WAI/WCAG22/Understanding/${(wcagMap as Record)[numericID]}`; +}; +// Gets the name of an issue weight. +export const getWeightName = (weight: number): string => ['lowest', 'low', 'high', 'highest'][weight - 1] ?? 'unknown'; +// Makes a string breakable before non-initial slashes. +export const makeBreakable = (string: string): string => string.replace(/\//g, '/').replace(/^/, ''); +// Returns the path ID of the element of a standard instance. +export const getPathID = (catalog: Catalog, catalogIndex: string, pathID?: string) => { + if (catalogIndex) { + const pathIDFromCatalog = (catalog[catalogIndex] || {}).pathID; + if (pathIDFromCatalog) { + return pathIDFromCatalog; + } + } + return pathID ?? '/html'; +}; +// Returns basics about an available report. +export const getReportData = async (timeStamp: string, jobID: string): Promise => { + // Get the report. + const report = await getReport(timeStamp, jobID); + // If this failed: + if (isReportError(report)) { + // Return why. + return {error: report.error}; + } + // Otherwise, i.e. if it succeeded, initialize the data. + const data = { + description: report.target.what, + url: report.target.url, + jobName: report.id, + creationDate: getDateTime(timeStamp), + daysAgo: getAgoDays(timeStamp), + issueCount: 0, + engineNames: [] as string[], + engineCount: 0, + reporterNames: [] as string[], + reporterCount: 0, + violatorCount: 0, + preventedEngineNames: [] as string[], + preventedEngineCount: 0 + }; + const issueIDSet = new Set(); + const engineNameSet = new Set(); + const reporterIDSet = new Set(); + const violatorIndexSet = new Set(); + // For each test act of the report: + getTestActs(report).forEach(act => { + // Ensure that the rule engine is in the temporary data. + engineNameSet.add(ruleEngines[act.which!]![0]); + }); + // For each violating standard instance of each test act: + getTestActInstances(report, {violationsOnly: true}).forEach(({act, instance}) => { + const {catalogIndex, issueID} = instance; + // If it has a non-ignorable classified issue ID: + if (issueID && issueSpecs[issueID] && issueID !== 'ignorable') { + // Ensure that the rule engine is in the temporary data. + reporterIDSet.add(act.which!); + // Ensure that the issue is in the temporary data. + issueIDSet.add(issueID); + // If the violator has a catalog index: + if (catalogIndex) { + // Ensure that the violator is in the temporary data. + violatorIndexSet.add(String(catalogIndex)); + } + } + }); + // Populate the data with the act data. + data.issueCount = issueIDSet.size; + data.engineNames = Array + .from(engineNameSet) + .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'})); + data.engineCount = engineNameSet.size; + data.reporterNames = Array + .from(reporterIDSet) + .map(id => ruleEngines[id]![0]) + .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'})); + data.reporterCount = data.reporterNames.length; + data.violatorCount = violatorIndexSet.size; + // Add the names of any prevented rule engines to the data. + data.preventedEngineNames = Object.keys(report.jobData?.preventions || {}) + .map(engineID => ruleEngines[engineID]?.[0] || engineID) + .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'})); + data.preventedEngineCount = data.preventedEngineNames.length; + // Return the data. + return data; +} +// Returns page data from an available report. +export const getPageData = async (timeStamp: string, jobID: string): Promise => { + // Get the report. + const report = await getReport(timeStamp, jobID); + // If this failed: + if (isReportError(report)) { + // Return why. + return report; + } + const {what: description, url} = report.target; + // Get the elapsed time in days since the report was completed, using the + // report content rather than the file system birth time. + const daysAgo = getAgoDays(new Date(`20${report.jobData.endTime}Z`)); + // Return the data. + return { + description, + url, + daysAgo + }; +}; +// Gets HTML strings for page data from a report. +export const getPageDataStrings = async ( + timeStamp: string, + jobID: string, + pageData?: PageData | {error: string} +): Promise => { + // Get the page data if they were not specified. + const data = pageData ?? await getPageData(timeStamp, jobID); + // If the page data are invalid: + if (data.error !== undefined) { + // Return why. + return { + error: data.error + }; + } + const {daysAgo, url, description} = data; + // Otherwise, i.e. if they are valid, get a description of the timestamp. + const when = getDateTimeString(timeStamp); + // Return the HTML strings. + return { + description, + url, + urlLink: `${url}`, + testInfo: `Tested ${daysAgo === 1 ? '1 day' : `${daysAgo} days`} ago by job ${jobID} on ${when}` + }; +}; +// Gets the descriptions of multi-report pages. +export const getMultiReportWhats = async (): Promise => { + const reportExtracts = await getReportExtracts(); + const sortedDescriptions = reportExtracts.map(extract => extract.description).sort(); + const multiReportDescriptions = sortedDescriptions.filter( + (description, index) => description !== sortedDescriptions[index - 1] && description === sortedDescriptions[index + 1] + ); + return multiReportDescriptions; +};