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;
+};